Compare commits

..
9 Commits
Author SHA1 Message Date
fanyang 7a9b3d7b28 ci: install LTTng headers for feature checks 2026-06-30 09:30:12 +08:00
fanyang 12bfb8dbec fix(core): preserve LTTng generation errors 2026-06-30 00:24:57 +08:00
fanyang 372b6e15fe feat(core): add gated LTTng tracing 2026-06-30 00:17:58 +08:00
fanyang be0b4c7512 refactor(core): remove hotpath profiling
hotpath adds noticeable overhead on hot paths, so remove the optional
profiling integration while keeping direct quanta::Instant timing.
2026-06-30 00:17:14 +08:00
KKRainbow 425a24273b fix: preserve secure relay sessions during cleanup (#2393)
* fix: skip secure relay packets in peer conn filter
* fix: keep active secure relay sessions during GC
2026-06-30 00:04:17 +08:00
KKRainbow 4e61612944 fix(web): preserve managed config and status compatibility (#2389)
Fix web/frontend compat bugs in managed config & runtime status

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

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

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

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

packet sourced from the same IPv6 address that the connector is dialing.
2026-06-28 20:42:16 +08:00
KKRainbow 9cb3833216 perf(easytier-web): improve easytier-web webhook performance (#2383)
* feat(web): reconcile managed config revisions
* feat(web): cache managed runtime configs per session
* test: cover managed web config delivery
2026-06-28 13:14:40 +08:00
108 changed files with 8752 additions and 4011 deletions
+1 -1
View File
@@ -35,6 +35,7 @@ jobs:
with: with:
gui: false gui: false
pnpm: false pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
@@ -243,4 +244,3 @@ jobs:
ohpm publish easytier-release.har ohpm publish easytier-release.har
fi fi
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }} curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
+5
View File
@@ -51,6 +51,11 @@ jobs:
pnpm: true pnpm: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Install LTTng UST headers
run: |
sudo apt-get update
sudo apt-get install -y liblttng-ust-dev
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
components: rustfmt,clippy components: rustfmt,clippy
Generated
+60 -161
View File
@@ -257,12 +257,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -778,6 +772,29 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bindgen"
version = "0.65.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5"
dependencies = [
"bitflags 1.3.2",
"cexpr",
"clang-sys",
"lazy_static",
"lazycell",
"log",
"peeking_take_while",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex",
"syn 2.0.117",
"which 4.4.2",
]
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -791,7 +808,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
"rustc-hash", "rustc-hash 2.1.0",
"shlex", "shlex",
"syn 2.0.117", "syn 2.0.117",
] ]
@@ -1254,12 +1271,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]] [[package]]
name = "cidr" name = "cidr"
version = "0.3.1" version = "0.3.1"
@@ -2100,16 +2111,6 @@ dependencies = [
"dirs-sys 0.5.0", "dirs-sys 0.5.0",
] ]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]] [[package]]
name = "dirs-sys" name = "dirs-sys"
version = "0.3.7" version = "0.3.7"
@@ -2133,17 +2134,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users 0.4.5",
"winapi",
]
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.1" version = "0.3.1"
@@ -2322,7 +2312,6 @@ dependencies = [
"hickory-resolver", "hickory-resolver",
"hickory-server", "hickory-server",
"hmac", "hmac",
"hotpath",
"http", "http",
"http_req", "http_req",
"humansize", "humansize",
@@ -2332,6 +2321,8 @@ dependencies = [
"indoc", "indoc",
"itertools 0.14.0", "itertools 0.14.0",
"kcp-sys", "kcp-sys",
"lttng-ust",
"lttng-ust-generate",
"machine-uid", "machine-uid",
"maplit", "maplit",
"mimalloc", "mimalloc",
@@ -2365,7 +2356,6 @@ dependencies = [
"quanta", "quanta",
"quinn", "quinn",
"quinn-proto", "quinn-proto",
"quinn-udp",
"quote", "quote",
"rand 0.8.5", "rand 0.8.5",
"rcgen", "rcgen",
@@ -2666,12 +2656,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "encoding" name = "encoding"
version = "0.2.33" version = "0.2.33"
@@ -3930,61 +3914,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "hotpath"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc2c28b1fa962e433f800ed1ea0bf53dc028d3745cf2acec6cfd28b65ac96afa"
dependencies = [
"arc-swap",
"cfg-if",
"crossbeam-channel",
"flate2",
"flume 0.12.0",
"futures-util",
"hdrhistogram",
"hotpath-macros",
"hotpath-meta",
"libc",
"object",
"parking_lot",
"pin-project-lite",
"prettytable-rs",
"quanta",
"regex",
"rustc-demangle",
"serde",
"serde_json",
"tiny_http",
"tokio",
]
[[package]]
name = "hotpath-macros"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a585238d8daf746e27df0f24d1bbdcd2410e9febff63f9a0173f90d7e71c50f6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "hotpath-macros-meta"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "309f63c2f755dead454dd4b3ea8ab5c947f14f8ea435fbcd37fa820e17290e80"
[[package]]
name = "hotpath-meta"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68faa91a9e1114dff668cd90560f332da6bbde40dae37ec28ea1c43ca5ce3be3"
dependencies = [
"hotpath-macros-meta",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.29.1"
@@ -4592,17 +4521,6 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "is-wsl" name = "is-wsl"
version = "0.4.0" version = "0.4.0"
@@ -4771,7 +4689,7 @@ source = "git+https://github.com/EasyTier/kcp-sys?rev=d7427c22d764deb1860a7d37ac
dependencies = [ dependencies = [
"anyhow", "anyhow",
"auto_impl", "auto_impl",
"bindgen", "bindgen 0.72.1",
"bitflags 2.8.0", "bitflags 2.8.0",
"bytes", "bytes",
"cc", "cc",
@@ -4818,6 +4736,12 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -4881,7 +4805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets 0.52.6", "windows-targets 0.48.5",
] ]
[[package]] [[package]]
@@ -5011,6 +4935,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lttng-ust"
version = "0.1.0"
source = "git+https://github.com/EasyTier/lttng-ust-rs.git#fb3a7a86e9c3d8edad34cc70fd48f908b745f8d9"
[[package]]
name = "lttng-ust-generate"
version = "0.1.1"
source = "git+https://github.com/EasyTier/lttng-ust-rs.git#fb3a7a86e9c3d8edad34cc70fd48f908b745f8d9"
dependencies = [
"bindgen 0.65.1",
"cc",
]
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@@ -5953,15 +5891,6 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -6339,6 +6268,12 @@ dependencies = [
"hmac", "hmac",
] ]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.4" version = "3.0.4"
@@ -6832,19 +6767,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "prettytable-rs"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
dependencies = [
"encode_unicode",
"is-terminal",
"lazy_static",
"term",
"unicode-width 0.1.11",
]
[[package]] [[package]]
name = "primeorder" name = "primeorder"
version = "0.13.6" version = "0.13.6"
@@ -7196,7 +7118,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash 2.1.0",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.5.10",
"thiserror 2.0.11", "thiserror 2.0.11",
@@ -7217,7 +7139,7 @@ dependencies = [
"lru-slab", "lru-slab",
"rand 0.9.1", "rand 0.9.1",
"ring", "ring",
"rustc-hash", "rustc-hash 2.1.0",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -7905,10 +7827,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-hash"
version = "0.1.27" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
@@ -9834,17 +9756,6 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "term"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
dependencies = [
"dirs-next",
"rustversion",
"winapi",
]
[[package]] [[package]]
name = "terminal_size" name = "terminal_size"
version = "0.4.1" version = "0.4.1"
@@ -10000,18 +9911,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "825f6c8a18bc36d56a62f66af7296385b628c9c5543a8663d4c217fc920bfefd" checksum = "825f6c8a18bc36d56a62f66af7296385b628c9c5543a8663d4c217fc920bfefd"
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.7.6" version = "0.7.6"
@@ -11426,7 +11325,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
-6
View File
@@ -27,9 +27,3 @@ lto = true
codegen-units = 1 codegen-units = 1
opt-level = 3 opt-level = 3
strip = true strip = true
# For hotpath CPU profiling: samply needs debug symbols and unstripped binaries.
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
@@ -1,426 +0,0 @@
# hotpath CPU 热点分析与发包链路优化
## 概述
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
## 最终 benchmark 数据
### 真实性能对比(不带 hotpath,3 runs average)
origin/main baseline 使用 `git worktree` 从 origin/main 构建,仅添加 bench example +
loopback bind fix(TCP/UDP convergence 需要)。无任何优化代码。
| Tunnel | origin/main baseline | 优化后 | **提升** | 带宽(优化后) |
| -------- | -------------------- | -------------- | --------- | -------------- |
| **Ring** | 293K pps / 3.3 Gbps | **1,124K pps** | **+284%** | 12.6 Gbps |
| **TCP** | 298K pps / 3.3 Gbps | **975K pps** | **+227%** | 10.9 Gbps |
| **UDP** | 630K pps / 7.1 Gbps | **1,066K pps** | **+69%** | 11.9 Gbps |
UDP baseline 本身较高(630K vs 293K/298K),因为 UDP tunnel 的 forward_from_ring_to_udp
独立 task 提供了天然的 pipeline overlap,部分隐藏了 channel 开销。
### 带 hotpath profiling(timing 可见,但有 observer effect)
| Tunnel | 原始 pps | 优化后 pps | 提升 | MpscTunnelSender::send |
| ------ | -------- | ---------- | ----- | ---------------------- |
| Ring | 234K | 478K | +104% | 138ns (原 2.23µs) |
| UDP | N/A | 440K | — | 294ns |
| TCP | N/A | 453K | — | 378ns |
### hotpath observer effect
**hotpath 测量基础设施引入了 ~54-57% 的性能开销:**
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**含义:**
- timing 数据里的 `send_msg_by_ip: 2.15µs` 是膨胀值,真实成本 ~0.9µs
- 所有 timing 数据需要按 ~2.3x 校准才能反映真实开销
- hotpath 适用于相对比较(优化前 vs 后),不适用于绝对性能评估
- 生产环境部署不应用 hotpath feature 编译
测试条件:4 threads, 1400B packets, 10s, 宿主机直跑。
---
## 工具链搭建
### hotpath + samply 安装
```bash
# hotpath TUI (console)
cargo install hotpath --version 0.18.0 --bin hotpath --features tui
# hotpath-samply (samply wrapper,autospawn 依赖)
cargo install hotpath --version 0.18.0 --bin hotpath-samply
# samply 本体(hotpath-samply 内部 spawn samply record)
cargo install samply
```
### 内核参数
samply 需要 perf_event 开销,需要调整内核参数:
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
- `perf_event_paranoid` 默认 2(不允许非 root 采样),需降到 1。
- `perf_event_mlock_kb` 默认 516 KB,32 核机器上 samply 的 mmap buffer 总量超限,需增大到 65536。
### hotpath profile 编译
```toml
# Cargo.toml
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
```
samply 需要 debug symbols 且不能 strip。release profile 默认 `strip = true`,必须用单独的 profile。
### Docker 隔离环境(可选,TCP/UDP bench)
修复 loopback bind 地址后(见坑 11),TCP/UDP bench 可以直接在宿主机上跑,不需要 Docker:
```bash
# Ring(进程内,无需隔离)
HOTPATH_TUNNEL=ring ./target/hotpath/examples/cpu_hotspot_ring
# TCP/UDP(修复后也支持宿主机直跑)
HOTPATH_TUNNEL=tcp ./target/hotpath/examples/cpu_hotspot_ring
```
如果仍有 convergence 问题(多网卡环境),用 Docker 提供独立 netns:
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
Docker 镜像需要匹配宿主机的 glibc 版本。Fedora 宿主用 `fedora:latest`。
---
## 踩坑记录
### 坑 1:samply 报 "failed to spawn samply: No such file or directory"
**现象**:hotpath CPU report 显示 `failed to spawn samply: No such file or directory (os error 2)`
**原因**:hotpath-samply 只是 wrapper,它内部 spawn `samply record --pid <pid>` 来采集 CPU 样本。samply 本体没装。
**解决**:
```bash
cargo install samply
```
如果 autospawn 找不到 hotpath-samply 本身,用环境变量指定完整路径:
```bash
export HOTPATH_SAMPLY_WRAPPER_BIN=~/.cargo/bin/hotpath-samply
```
### 坑 2:samply 报 "Failed to start profiling: mmap failed"
**现象**:samply 启动后立即报 mmap 失败。
**原因**:`perf_event_mlock_kb` 默认只有 516 KB。32 核机器上 samply 为每个 CPU core 创建 mmap buffer,总 mmap 量超过限制。
**解决**:
```bash
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
### 坑 3:samply 报 "samply exited with status exit status: 1"
**现象**:samply 被 spawn 了但 exit 1。
**原因**:同坑 2——`perf_event_paranoid = 2` 时非 root 用户无法使用 perf_event_open。
**解决**:
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
```
### 坑 4:火焰图全是地址,看不到符号
**现象**:samply profile 打开后火焰图全是 `0x31dd24` 之类的地址。
**原因**:samply profile 里存储的是地址(不内联符号化)。符号化在查看时通过 symbol server 动态完成。如果直接下载 raw JSON 上传到 profiler.firefox.com,符号 server 无法访问本地二进制文件。
**解决**:必须用 `samply load` 本地打开(它启动 symbol server 自动做符号化):
```bash
samply load /tmp/hotpath/<session>/hp.json.gz
```
不要下载 JSON 再上传到 profiler.firefox.com。
### 坑 5:samply 符号化后 `_dl_mcount_wrapper` 占 18.1%
**现象**:send_msg_internal inclusive 分析显示 `_dl_mcount_wrapper` 占 18.1% CPU。
**原因**:nm 的动态符号表里 `_dl_mcount_wrapper`(0x1498d0)到下一个符号(0x1b3e9e)之间有 **425 KB gap**。nm 的 bisect 查找把 gap 内所有地址错误归因到 `_dl_mcount_wrapper`。gap 里实际是 AVX2 优化的 memmove/memcmp/memset 等函数。
**解决**:用 addr2line 精确解析(而非 nm bisect)。实际开销是 memmove 1.67% + memcmp 0.20% + memset 0.19% = 2.1%,不是 18%。**没有 profiling 钩子**。
### 坑 6:parking_lot::MutexGuard 不是 Send
**现象**:使用 `parking_lot::Mutex` 替代 `tokio::sync::Mutex` 后,编译报 31 个 "future cannot be sent between threads safely"。
**原因**:`parking_lot::MutexGuard` 刻意不实现 `Send`——锁必须在获取它的同一个线程上释放。在 async fn 里 guard 跨 await 点会导致 Future 不是 Send,tokio multi_thread runtime 拒绝 spawn。
**解决**:自定义 `SpinSink`(AtomicBool spinlock),`SpinGuard` 只持有 `&SpinSink` 引用(SpinSink: Sync via unsafe impl),是 Send。
### 坑 7:std::sync::MutexGuard 也不是 Send(在某些配置下)
**现象**:`std::sync::Mutex` 同样报 "future cannot be sent between threads safely"。
**原因**:Rust 标准库的 `MutexGuard` 的 Send 实现依赖于内部类型。`Pin<Box<dyn ZCPacketSink>>` 包含 trait object,某些配置下 guard 不是 Send。
**解决**:用自定义 SpinSink 绕过所有标准 Mutex 实现。
### 坑 8:direct sink path 没有性能提升
**现象**:去掉 channel 中转(MpscTunnelSender 直接持有 sink),从 3 个 await 点(lock + feed + flush)改为 try_lock + poll_fn 合并。MpscTunnelSender::send 仍然 ~2µs。
**原因**:瓶颈不在 lock 或 channel,而在 **async fn Future 状态机的固有开销**。每次 `.await` 创建一个 Future struct、poll 它、drop 它。即使 poll 立即返回 Ready,整个 async machinery 开销 ~2µs。RingSink 实际操作只有 ~40ns(2%)。
**解决**:用 `noop_waker()` 在 async fn 内部同步调用 Sink trait 方法(poll_ready + start_send + poll_flush)。async fn 在第一次 poll 就同步完成返回——绕过所有 async 调度开销。开销从 2µs 降到 ~140ns。
### 坑 9:sync send 破坏了 TCP/UDP tunnel
**现象**:把 `send` 从 `async fn` 改为 sync `fn` 后,所有 TCP/UDP 相关测试失败(452 个失败)。
**原因**:TCP/UDP tunnel 用 channel mode(`MpscTunnel::new`)。sync `send` 的 channel path 只做 `try_send`,channel 满时返回 `BufferFull`(丢包),而不是 `send().await`(等待背压)。丢包导致 TCP/UDP 连接握手失败。
**解决**:保持 `send` 为 async fn。direct path(ring/UDP/TCP)内部用 noop_waker 同步完成(不 yield)。channel path 仍然走 async `send_async().await`。async fn wrapper 对 direct path 只有 ~100ns 开销(Future struct 创建 + 单次 poll),因为不 yield。
### 坑 10:poll_flush Pending 返回 Shutdown 导致连接断开
**现象**:noop_waker 模式下,TCP tunnel 的 `poll_flush` 可能返回 Pending(TCP 写缓冲区满)。返回 `Err(Shutdown)` 导致 PeerConn 认为连接断开。
**原因**:TCP 的 `FramedWriter::poll_flush` 做实际 socket write(系统调用)。socket 缓冲区满时返回 Pending。数据已经在 BufList 里,不需要 panic。
**解决**:poll_flush Pending 时返回 `Ok(())`。数据已在 buffer(ring buffer 或 BufList),后续操作会消费它。Pending 只意味着 "还没 flush 到网络",不是 "错误"。
### 坑 11:TCP/UDP bench convergence 失败
**现象**:TCP/UDP tunnel 的 bench 中,两个实例无法建立连接(routes did not converge within 15s)。
**原因**:`set_bind_addr_for_peer_connector`(connector/mod.rs:70-77)收集所有本机 IP 作为 TCP bind 地址,但不包含 `127.0.0.1`。connector 绑定到 `172.17.0.2`(Docker eth0)后连接 `127.0.0.1` 路由不通 → 2 秒超时。
**解决**:在 bind 地址列表头部加入 `127.0.0.1:0`。connector 遍历所有 bind 地址,loopback 先被尝试,localhost 连接成功。
### 坑 12:ShardedCounter (#2385) 在高频路径引入回退
**现象**:cherry-pick PR #2385(ShardedCounter 替代 UnsafeCell)后,pps 下降 17%(246K → 203K)。
**原因**:ShardedCounter 的 TLS 分片设计优化多线程 contention,但每包调用 16 次 `ShardedCounter::add`(TLS load + store),单次 ~14ns,总 224ns/包。比原来的 `UnsafeCell`(~2ns/次)高 6 倍。每包 16 次的调用频率让 TLS 开销累积。
**教训**:TLS 分片策略适合 **低频高并发** 场景,不适合 **高频单线程** 的发包热路径。
### 坑 13:ZCPacket pool 不如 glibc tcache
**现象**:用 `crossbeam_queue::ArrayQueue` 做 BytesMut 对象池,每包从池取/归还。性能没有提升(甚至 -15%)。
**原因**:glibc malloc 对 ~1500 bytes 小块分配有 thread-local cache(tcache),单次 alloc ~10-15ns。ArrayQueue 的 pop/push 是 CAS 操作(~20-40ns),比 tcache 更慢。pool 还多了 capacity 检查和 clear 操作。
**教训**:手动对象池在现代 glibc tcache 面前没有优势。真正需要 pool 的场景是避免 munmap(大块 >128KB 分配),不是小块。
### 坑 14:Pipeline (FuturesUnordered) 效果微小
**现象**:用 FuturesUnordered 让多个 send_msg_by_ip 并发(pipeline_depth=4),pps 只提升 1.6%。
**原因**:try_send fast path 让 MpscTunnelSender::send 立即返回(不 await)。多个 send_msg_by_ip 之间没有自然的时间重叠——它们在 CPU 上是串行的。pipeline 需要利用 await 等待时间,但 fast path 消除了 await。
### 坑 15:hotpath 测量引入 54% observer effect
**现象**:同一 binary 带 hotpath feature 和不带 hotpath feature 跑 bench,pps 差距巨大。
**数据**:
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**原因**:hotpath `#[measure]` / `#[measure_all]` 在每个标注的 async fn 上包装 Future struct,每次 poll 记录开始/结束时间(quanta::Instant ~5ns × 2)、更新统计(atomic 操作)。measure_all 覆盖的 impl 块内所有方法都被插桩。当有 ~30 个 measure 点在发包热路径上时,累计开销超过 50%。
**教训**:
- hotpath timing 数据**适用于相对比较**(优化前 vs 后),**不适用于绝对性能评估**
- 生产环境**不应**用 hotpath feature 编译
- 要获取真实 pps,编译不带 `--features hotpath` 的版本
- timing 数据按 ~2.3x 校准可近似真实开销
---
## 优化实施记录
### 真实提升(不带 hotpath,origin/main baseline 对比)
baseline 构建:`git worktree` 从 origin/main,仅添加 bench example + loopback bind fix。
| Tunnel | baseline | 优化后 | 提升 |
| ------ | -------- | -------------- | --------- |
| Ring | 293K pps | **1,124K pps** | **+284%** |
| TCP | 298K pps | **975K pps** | **+227%** |
| UDP | 630K pps | **1,066K pps** | **+69%** |
### 有效优化(按贡献排序)
| 优化 | 带 hotpath pps 变化 | 真实提升来源 | 机制 |
| ------------------------ | ------------------- | ------------------------ | ---------------------------------------------------------- |
| **noop_waker sync send** | **+90%** | **核心突破** | RingSink/FramedWriter 直接 sync poll,绕过 async machinery |
| try_send fast path | +7% | 次要 | 跳过 tokio mpsc semaphore |
| #2385 ZCPacket safe init | +5% (TCP) | TCP 专属 | copy_nonoverlapping 无 aliasing 检查 |
| metrics batch + sync | +1.6% | 小幅 | batch CounterHandle + sync fast path |
| #2381 advance (零拷贝) | ~0% | 代码质量 | Buf::advance 消除 split_off Arc churn |
| channel 32→1024 | ~0% | 减少 fallback | 更大 buffer |
| 接收侧 try_recv | ~0% (单向) | 双向有价值 | 消除 recv().await async overhead |
| loopback bind fix | — | TCP/UDP convergence 必需 | 127.0.0.1 加入 bind 地址列表 |
### 验证无效并回退
| 尝试 | 结果 | 原因 |
| ---------------------------------- | -------- | ----------------------------------- |
| ShardedCounter (#2385) | -17% pps | TLS 分片高频开销 > UnsafeCell |
| ZCPacket pool | -15% pps | glibc tcache 比 ArrayQueue CAS 更快 |
| Allocator 切换 (jemalloc/mimalloc) | ~0% | 小块分配 tcache 都已足够 |
| Pipeline (FuturesUnordered) | +1.6% | try_send 消除了 await 空隙 |
| dashmap 合并 | ~0% | contains_key 本身 ~50ns |
### noop_waker 技术详解
核心原理:async fn `send()` 内部用 `noop_waker()` 构造 dummy Context,直接调 Sink trait 的 `poll_ready` + `start_send` + `poll_flush`。RingSink 在 ring buffer 不满时所有操作立即返回 Ready——noop_waker 永远不会被触发。
```rust
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink {
if let Some(mut guard) = sink.try_lock() {
let waker = futures::task::noop_waker();
let mut cx = std::task::Context::from_waker(&waker);
match guard.as_mut().poll_ready(&mut cx) {
Poll::Ready(Ok(())) => {
guard.as_mut().start_send(item)?;
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()), // Ready(Ok) 或 Pending 都返回 Ok
}
}
// ...
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
}
```
**为什么 Pending 返回 Ok**:poll_flush Pending 意味着数据已在 buffer(ring buffer 或 BufList)但还没 flush 到网络。forward task 或下一次 send 会消费它。这是安全的——数据不丢、不乱序。
**适用范围**:所有 Sink 的 `start_send` 是同步内存操作的 tunnel:
- Ring tunnel: RingSink → ring buffer(内存)
- UDP tunnel: RingSink → ring buffer → forward_from_ring_to_udp task → socket
- TCP tunnel: FramedWriter → BufList(内存)→ poll_flush 时 write socket
---
## hotpath measure 布点
### 当前覆盖
```
send_msg_by_ip ✅ measure
├─ try_compress_and_encrypt ✅ measure
├─ get_msg_dst_peer_ipv4 ✅ measure
├─ run_nic_packet_process_pipeline ✅ measure
├─ send_msg_internal ✅ measure
│ ├─ PeerMap::send_msg_directly ✅ measure_all
│ ├─ PeerMap::get_peer_by_id ✅ measure_all
│ ├─ PeerMap::get_gateway_peer_id ✅ measure_all
│ ├─ PeerMap::has_peer ✅ measure_all
│ ├─ record_tx_fast ❌ (sync fn, 无 measure)
│ └─ Peer::send_msg ✅ measure
│ └─ PeerConn::send_msg ✅ measure
│ └─ MpscTunnelSender::send ✅ measure
├─ MpscTunnel::forward_one_round ✅ measure
│ ├─ RingSink::poll_ready ✅ measure_all
│ ├─ RingSink::start_send ✅ measure_all
│ └─ RingSink::poll_flush ✅ measure_all
└─ CidrSet::* ✅ measure_all
```
### 布点排除项(避免与已有 PR 冲突)
| 文件 | 排除原因 |
| ---------------------- | -------------------- |
| stats_manager.rs | PR #2385 重写中 |
| traffic_metrics.rs | 依赖 stats_manager |
| peer_manager.rs (部分) | advisor/001-002 改动 |
| peer_conn.rs (部分) | advisor/001-002 改动 |
| tunnel/mpsc.rs (部分) | perf/001 改动 |
| packet_def.rs | perf/001-003 改动 |
| peer_ospf_route.rs | advisor/003-004 改动 |
---
## 运行方式
### Ring tunnel bench
```bash
cargo build --profile hotpath --features hotpath --example cpu_hotspot_ring
HOTPATH_BENCH_SECS=15 ./target/hotpath/examples/cpu_hotspot_ring
```
### TCP/UDP bench(需要 Docker 隔离)
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
### 带 samply CPU profiling
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
# 另一终端查看 CPU top
hotpath console
```
### 环境变量
| 变量 | 默认 | 说明 |
| ---------------------------- | ---- | ----------------------- |
| `HOTPATH_BENCH_SECS` | 30 | 打流持续秒数 |
| `HOTPATH_PKT_SIZE` | 1400 | 包大小 |
| `HOTPATH_TUNNEL` | ring | ring / udp / tcp |
| `HOTPATH_PIPELINE` | 1 | pipeline 深度 |
| `HOTPATH_SAMPLY_WRAPPER_BIN` | — | hotpath-samply 完整路径 |
| `HOTPATH_SAMPLY_BIN` | — | samply 本体完整路径 |
+11 -14
View File
@@ -163,14 +163,10 @@ async function registerVpnServiceListener() {
) )
} }
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] { function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] {
if (!routes) {
return []
}
const ret = [] const ret = []
for (const r of routes) { for (const r of routes ?? []) {
for (let cidr of r.proxy_cidrs) { for (let cidr of r.proxy_cidrs ?? []) {
if (!cidr.includes('/')) { if (!cidr.includes('/')) {
cidr += '/32' cidr += '/32'
} }
@@ -178,9 +174,9 @@ function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfi
} }
} }
node_config.routes.forEach(r => { for (const route of node_config.routes ?? []) {
ret.push(r) ret.push(route)
}) }
if (node_config.enable_magic_dns) { if (node_config.enable_magic_dns) {
ret.push('100.100.100.101/32') ret.push('100.100.100.101/32')
@@ -215,14 +211,15 @@ export async function onNetworkInstanceChange(instanceId: string) {
console.log('vpn service skipped because no_tun is enabled', instanceId) console.log('vpn service skipped because no_tun is enabled', instanceId)
return return
} }
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId] const curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg) console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
await doStopVpn() await doStopVpn()
return return
} }
const virtual_ip = Utils.ipv4ToString(curNetworkInfo?.my_node_info?.virtual_ipv4.address) const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4
const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined
if (config.dhcp && (!virtual_ip || !virtual_ip.length)) { if (config.dhcp && (!virtual_ip || !virtual_ip.length)) {
console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms') console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms')
@@ -237,7 +234,7 @@ export async function onNetworkInstanceChange(instanceId: string) {
return return
} }
let network_length = curNetworkInfo?.my_node_info?.virtual_ipv4.network_length let network_length = virtualIpv4?.network_length
if (!network_length) { if (!network_length) {
network_length = 24 network_length = 24
} }
@@ -290,7 +287,7 @@ async function isNoTunEnabled(instanceId: string | undefined) {
async function findRunningTunInstanceId() { async function findRunningTunInstanceId() {
const instanceIds = await listNetworkInstanceIds() const instanceIds = await listNetworkInstanceIds()
const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr) const runningIds = (instanceIds.running_inst_ids ?? []).map(Utils.UuidToStr)
console.log('vpn service sync running instances', JSON.stringify(runningIds)) console.log('vpn service sync running instances', JSON.stringify(runningIds))
for (const instanceId of runningIds) { for (const instanceId of runningIds) {
+2 -2
View File
@@ -9,7 +9,7 @@ export class GUIRemoteClient implements Api.RemoteClient {
await backend.runNetworkInstance(config, save); await backend.runNetworkInstance(config, save);
} }
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> { async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
return backend.collectNetworkInfo(inst_id).then(infos => infos.info.map[inst_id]); return backend.collectNetworkInfo(inst_id).then(infos => infos.info?.map?.[inst_id]);
} }
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> { async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
return backend.listNetworkInstanceIds(); return backend.listNetworkInstanceIds();
@@ -44,4 +44,4 @@ export class GUIRemoteClient implements Api.RemoteClient {
return await backend.getNetworkMetas(instance_ids); return await backend.getNetworkMetas(instance_ids);
} }
} }
@@ -114,6 +114,12 @@ function allFieldFixture() {
networking_method: NetworkingMethod.Manual, networking_method: NetworkingMethod.Manual,
public_server_url: 'tcp://public.example:11010', public_server_url: 'tcp://public.example:11010',
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'], peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
peers: [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
],
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'], proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
enable_vpn_portal: true, enable_vpn_portal: true,
vpn_portal_listen_port: 23000, vpn_portal_listen_port: 23000,
@@ -259,6 +265,8 @@ function assertFullFieldRoundTrip() {
assert.equal(backend.networking_method, 'Manual') assert.equal(backend.networking_method, 'Manual')
assert.equal(backend.public_server_url, '') assert.equal(backend.public_server_url, '')
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010']) assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
assert.equal(backend.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(backend.peers[1], { uri: 'udp://peer-b:11010' })
assert.equal(backend.data_compress_algo, 'Zstd') assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.instance_recv_bps_limit, '9007199254740993') assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.secure_mode.enabled, true) assert.equal(backend.secure_mode.enabled, true)
@@ -415,6 +423,59 @@ function assertNetworkingMethodNormalization() {
}) })
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two']) assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
const publicServerMissingUrl = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'PublicServer',
public_server_url: '',
peer_urls: ['tcp://manual.example:11010'],
})
assert.deepEqual(publicServerMissingUrl.peer_urls, [])
}
function assertPeerPublicKeysPreserved() {
const normalized = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
peer_urls: [],
peers: [
{
uri: ' tcp://peer-a:11010 ',
peer_public_key: 'peer-a-public-key',
},
],
})
assert.deepEqual(normalized.peer_urls, ['tcp://peer-a:11010'])
assert.deepEqual(normalized.peers, [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
])
const unchangedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-a:11010', 'tcp://peer-b:11010'],
})
assert.equal(unchangedUrl.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(unchangedUrl.peers[1], { uri: 'tcp://peer-b:11010' })
const changedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-c:11010'],
})
assert.deepEqual(changedUrl.peers, [{ uri: 'tcp://peer-c:11010' }])
const clearedUrls = toBackendNetworkConfig({
...normalized,
peer_urls: [],
})
assert.deepEqual(clearedUrls.peer_urls ?? [], [])
assert.deepEqual(clearedUrls.peers ?? [], [])
} }
function assertNumberBoundaries() { function assertNumberBoundaries() {
@@ -469,6 +530,7 @@ const tests = [
assertEnumCompatibility, assertEnumCompatibility,
assertAclDefaultsAndExplicitZero, assertAclDefaultsAndExplicitZero,
assertNetworkingMethodNormalization, assertNetworkingMethodNormalization,
assertPeerPublicKeysPreserved,
assertNumberBoundaries, assertNumberBoundaries,
] ]
@@ -35,7 +35,7 @@ const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefin
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined); const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const isRunning = (instanceId: string) => { const isRunning = (instanceId: string) => {
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId); return (listInstanceIdResponse.value?.running_inst_ids ?? []).map(Utils.UuidToStr).includes(instanceId);
} }
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({}); const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
@@ -46,7 +46,7 @@ const loadNetworkMetas = async (instanceIds: string[]) => {
try { try {
const response = await props.api.get_network_metas(missingIds); const response = await props.api.get_network_metas(missingIds);
Object.assign(networkMetaCache.value, response.metas); Object.assign(networkMetaCache.value, response.metas ?? {});
} catch (e) { } catch (e) {
console.error("Failed to load network metas", e); console.error("Failed to load network metas", e);
} }
@@ -80,8 +80,8 @@ const updateInstanceList = () => {
let insts = new Set<string>(); let insts = new Set<string>();
let t = listInstanceIdResponse.value; let t = listInstanceIdResponse.value;
if (t) { if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u))); (t.running_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u))); (t.disabled_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
} }
const newList = Array.from(insts).map((instance: string) => { const newList = Array.from(insts).map((instance: string) => {
@@ -149,7 +149,7 @@ const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) { if (!selectedInstanceId.value) {
return false; return false;
} }
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid); return (listInstanceIdResponse.value?.disabled_inst_ids ?? []).map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
}); });
watch(networkIsDisabled, async (newVal, oldVal) => { watch(networkIsDisabled, async (newVal, oldVal) => {
if (newVal !== oldVal && newVal === true) { if (newVal !== oldVal && newVal === true) {
@@ -287,17 +287,35 @@ const loadNetworkInstanceIds = async () => {
} }
const loadCurrentNetworkInfo = async () => { const loadCurrentNetworkInfo = async () => {
if (!selectedInstanceId.value) { const selected = selectedInstanceId.value?.uuid;
if (!selected) {
curNetworkInfo.value = null;
return; return;
} }
if (!needShowNetworkStatus.value) { if (!needShowNetworkStatus.value) {
curNetworkInfo.value = null;
return;
}
if (curNetworkInfo.value?.instance_id !== selected) {
curNetworkInfo.value = null;
}
let network_info = await props.api.get_network_info(selected);
if (selectedInstanceId.value?.uuid !== selected) {
return; return;
} }
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid); if (!network_info) {
curNetworkInfo.value = {
instance_id: selected,
running: false,
error_msg: t('web.device_management.network_info_unavailable'),
} as NetworkTypes.NetworkInstance;
return;
}
curNetworkInfo.value = { curNetworkInfo.value = {
instance_id: selectedInstanceId.value.uuid, instance_id: selected,
running: network_info?.running ?? false, running: network_info?.running ?? false,
error_msg: network_info?.error_msg ?? '', error_msg: network_info?.error_msg ?? '',
detail: network_info, detail: network_info,
@@ -492,7 +510,7 @@ onUnmounted(() => {
<div class="flex items-center min-w-0"> <div class="flex items-center min-w-0">
<div class="mr-4 min-w-0 flex-1"> <div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{ <span class="truncate block">{{ t('network_name') }}: {{
slotProps.option.meta.network_name }}</span> slotProps.option.meta?.network_name ?? slotProps.option.uuid }}</span>
</div> </div>
<Tag class="my-auto leading-3 shrink-0" <Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'" :severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
@@ -569,10 +587,13 @@ onUnmounted(() => {
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2> <h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div> </div>
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo" <Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
class="mb-4"> class="mb-4">
</Status> </Status>
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message> <Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
curNetworkInfo.error_msg }}</Message>
<Message v-else severity="info" class="mb-4">{{ t('web.device_management.loading_network_status') }}
</Message>
<div class="text-center mt-4"> <div class="text-center mt-4">
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value" <Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network' import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils'; import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue'; import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
import NetworkChart from './NetworkChart.vue'; import NetworkChart from './NetworkChart.vue';
@@ -39,8 +39,8 @@ function routeCost(info: any) {
return '?' return '?'
} }
function resolveObjPath(path: string, obj = globalThis, separator = '.') { function resolveObjPath(path: string, obj: any = globalThis, separator = '.') {
const properties = Array.isArray(path) ? path : path.split(separator) const properties = path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj) return properties.reduce((prev, curr) => prev?.[curr], obj)
} }
@@ -48,10 +48,17 @@ function statsCommon(info: any, field: string): number | undefined {
if (!info.peer) if (!info.peer)
return undefined return undefined
const conns = info.peer.conns let sum = 0
return conns.reduce((acc: number, conn: any) => { let hasValue = false
return acc + resolveObjPath(field, conn) for (const conn of peerConns(info)) {
}, 0) const value = numericValue(resolveObjPath(field, conn))
if (value === undefined)
continue
sum += value
hasValue = true
}
return hasValue ? sum : undefined
} }
function humanFileSize(bytes: number, si = false, dp = 1) { function humanFileSize(bytes: number, si = false, dp = 1) {
@@ -74,14 +81,6 @@ function humanFileSize(bytes: number, si = false, dp = 1) {
return `${bytes.toFixed(dp)} ${units[u]}` return `${bytes.toFixed(dp)} ${units[u]}`
} }
function latencyMs(info: PeerRoutePair) {
let lat_us_sum = statsCommon(info, 'stats.latency_us')
if (lat_us_sum === undefined)
return ''
lat_us_sum = lat_us_sum / 1000 / info.peer!.conns.length
return `${lat_us_sum % 1 > 0 ? Math.round(lat_us_sum) + 1 : Math.round(lat_us_sum)}ms`
}
function txBytes(info: PeerRoutePair) { function txBytes(info: PeerRoutePair) {
const tx = statsCommon(info, 'stats.tx_bytes') const tx = statsCommon(info, 'stats.tx_bytes')
return tx ? humanFileSize(tx) : '' return tx ? humanFileSize(tx) : ''
@@ -92,11 +91,6 @@ function rxBytes(info: PeerRoutePair) {
return rx ? humanFileSize(rx) : '' return rx ? humanFileSize(rx) : ''
} }
function lossRate(info: PeerRoutePair) {
const lossRate = statsCommon(info, 'loss_rate')
return lossRate !== undefined ? `${Math.round(lossRate * 100)}%` : ''
}
function version(info: PeerRoutePair) { function version(info: PeerRoutePair) {
return info.route.version === '' ? 'unknown' : info.route.version return info.route.version === '' ? 'unknown' : info.route.version
} }
@@ -105,7 +99,7 @@ function ipFormat(info: PeerRoutePair) {
const ip = info.route.ipv4_addr const ip = info.route.ipv4_addr
if (typeof ip === 'string') if (typeof ip === 'string')
return ip return ip
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : '' return ip ? ipv4InetToString(ip) : ''
} }
function oneTunnelProto(tunnel?: TunnelInfo): string { function oneTunnelProto(tunnel?: TunnelInfo): string {
@@ -131,7 +125,7 @@ function oneTunnelProto(tunnel?: TunnelInfo): string {
} }
function tunnelProto(info: PeerRoutePair) { function tunnelProto(info: PeerRoutePair) {
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',') return [...new Set(peerConns(info).map(c => oneTunnelProto(c.tunnel)))].join(',')
} }
const myNodeInfo = computed(() => { const myNodeInfo = computed(() => {
@@ -206,7 +200,7 @@ const myNodeInfoChips = computed(() => {
// local ipv4s // local ipv4s
const local_ipv4s = my_node_info.ips?.interface_ipv4s const local_ipv4s = my_node_info.ips?.interface_ipv4s
for (const [idx, ip] of local_ipv4s?.entries()) { for (const [idx, ip] of local_ipv4s?.entries() ?? []) {
chips.push({ chips.push({
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`, label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
icon: '', icon: '',
@@ -215,7 +209,7 @@ const myNodeInfoChips = computed(() => {
// local ipv6s // local ipv6s
const local_ipv6s = my_node_info.ips?.interface_ipv6s const local_ipv6s = my_node_info.ips?.interface_ipv6s
for (const [idx, ip] of local_ipv6s?.entries()) { for (const [idx, ip] of local_ipv6s?.entries() ?? []) {
chips.push({ chips.push({
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`, label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
icon: '', icon: '',
@@ -226,7 +220,7 @@ const myNodeInfoChips = computed(() => {
const public_ip = my_node_info.ips?.public_ipv4 const public_ip = my_node_info.ips?.public_ipv4
if (public_ip) { if (public_ip) {
chips.push({ chips.push({
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`, label: `Public IP: ${ipv4ToString(public_ip)}`,
icon: '', icon: '',
} as Chip) } as Chip)
} }
@@ -241,7 +235,7 @@ const myNodeInfoChips = computed(() => {
// listeners: // listeners:
const listeners = my_node_info.listeners const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries()) { for (const [idx, listener] of listeners?.entries() ?? []) {
chips.push({ chips.push({
label: `Listener ${idx}: ${listener.url}`, label: `Listener ${idx}: ${listener.url}`,
icon: '', icon: '',
@@ -288,6 +282,14 @@ function natType(info: PeerRoutePair): string {
return '' return ''
} }
function isPublicServerRoute(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.is_public_server ?? false
}
function shouldAvoidRelayData(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.avoid_relay_data ?? false
}
const peerCount = computed(() => { const peerCount = computed(() => {
if (!peerRouteInfos.value) if (!peerRouteInfos.value)
return 0 return 0
@@ -342,7 +344,7 @@ function showEventLogs() {
if (!detail) if (!detail)
return return
dialogContent.value = detail.events.map((event: string) => JSON.parse(event)) dialogContent.value = detail.events?.map((event: string) => JSON.parse(event)) ?? []
dialogHeader.value = 'event_log' dialogHeader.value = 'event_log'
dialogVisible.value = true dialogVisible.value = true
} }
@@ -434,16 +436,16 @@ function showEventLogs() {
<Column :field="ipFormat" :header="t('virtual_ipv4')" /> <Column :field="ipFormat" :header="t('virtual_ipv4')" />
<Column :header="t('hostname')"> <Column :header="t('hostname')">
<template #body="slotProps"> <template #body="slotProps">
<div v-if="!slotProps.data.route.cost || !slotProps.data.route.feature_flag.is_public_server" <div v-if="!slotProps.data.route.cost || !isPublicServerRoute(slotProps.data)"
v-tooltip="slotProps.data.route.hostname"> v-tooltip="slotProps.data.route.hostname">
{{ {{
slotProps.data.route.hostname }} slotProps.data.route.hostname }}
</div> </div>
<div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1"> <div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1">
<Tag v-if="slotProps.data.route.feature_flag.is_public_server" severity="info" value="Info"> <Tag v-if="isPublicServerRoute(slotProps.data)" severity="info" value="Info">
{{ t('status.server') }} {{ t('status.server') }}
</Tag> </Tag>
<Tag v-if="slotProps.data.route.feature_flag.avoid_relay_data" severity="warn" value="Warn"> <Tag v-if="shouldAvoidRelayData(slotProps.data)" severity="warn" value="Warn">
{{ t('status.relay') }} {{ t('status.relay') }}
</Tag> </Tag>
</div> </div>
@@ -2,7 +2,7 @@
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue' import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network' import { AclAction, AclChain, AclChainType, AclProtocol, AclRule, ensureAclChain, ensureAclRuleLists } from '../../types/network'
import AclRuleDialog from './AclRuleDialog.vue' import AclRuleDialog from './AclRuleDialog.vue'
const props = defineProps<{ const props = defineProps<{
@@ -13,7 +13,11 @@ const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n() const { t } = useI18n()
watch(() => chain.value.rules, (newRules) => { function rules() {
return ensureAclChain(chain.value).rules
}
watch(() => rules(), (newRules) => {
if (!newRules) return if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0)) const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) { if (!isSorted) {
@@ -60,7 +64,7 @@ function addRule() {
editingRule.value = { editingRule.value = {
name: '', name: '',
description: '', description: '',
priority: chain.value.rules.length, priority: rules().length,
enabled: true, enabled: true,
protocol: AclProtocol.Any, protocol: AclProtocol.Any,
ports: [], ports: [],
@@ -79,28 +83,31 @@ function addRule() {
function editRule(index: number) { function editRule(index: number) {
editingRuleIndex.value = index editingRuleIndex.value = index
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index])) editingRule.value = ensureAclRuleLists(JSON.parse(JSON.stringify(rules()[index])))
showRuleDialog.value = true showRuleDialog.value = true
} }
function deleteRule(index: number) { function deleteRule(index: number) {
chain.value.rules.splice(index, 1) rules().splice(index, 1)
} }
function saveRule(rule: AclRule) { function saveRule(rule: AclRule) {
const chainRules = rules()
ensureAclRuleLists(rule)
if (editingRuleIndex.value === -1) { if (editingRuleIndex.value === -1) {
chain.value.rules.push(rule) chainRules.push(rule)
} else { } else {
chain.value.rules[editingRuleIndex.value] = rule chainRules[editingRuleIndex.value] = rule
} }
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0)) chainRules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
} }
function onRowReorder(event: any) { function onRowReorder(event: any) {
chain.value.rules = event.value chain.value.rules = event.value ?? []
const chainRules = rules()
// Update priorities based on new order (higher priority at top) // Update priorities based on new order (higher priority at top)
chain.value.rules.forEach((rule, index) => { chainRules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1 rule.priority = chainRules.length - index - 1
}) })
} }
</script> </script>
@@ -143,7 +150,7 @@ function onRowReorder(event: any) {
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" /> <Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
</div> </div>
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll"> <DataTable :value="rules()" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" /> <Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')"> <Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }"> <template #body="{ data }">
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue'; import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { GroupIdentity, GroupInfo } from '../../types/network'; import { GroupIdentity, GroupInfo, ensureGroupInfo } from '../../types/network';
const props = defineProps<{ const props = defineProps<{
groupNames?: string[] groupNames?: string[]
@@ -18,6 +18,17 @@ const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false) const showGroupDialog = ref(false)
const oldGroupName = ref('') const oldGroupName = ref('')
function groupInfo() {
return ensureGroupInfo(group.value)
}
const members = computed({
get: () => groupInfo().members,
set: value => {
groupInfo().members = value
},
})
function addGroup() { function addGroup() {
editingGroupIndex.value = -1 editingGroupIndex.value = -1
editingGroup.value = { editingGroup.value = {
@@ -30,13 +41,13 @@ function addGroup() {
function editGroup(index: number) { function editGroup(index: number) {
editingGroupIndex.value = index editingGroupIndex.value = index
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index])) editingGroup.value = JSON.parse(JSON.stringify(groupInfo().declares[index]))
oldGroupName.value = editingGroup.value?.group_name || '' oldGroupName.value = editingGroup.value?.group_name || ''
showGroupDialog.value = true showGroupDialog.value = true
} }
function deleteGroup(index: number) { function deleteGroup(index: number) {
group.value.declares.splice(index, 1) groupInfo().declares.splice(index, 1)
} }
function saveGroup() { function saveGroup() {
@@ -44,15 +55,15 @@ function saveGroup() {
const newName = editingGroup.value.group_name const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) { if (editingGroupIndex.value === -1) {
group.value.declares.push(editingGroup.value) groupInfo().declares.push(editingGroup.value)
} else { } else {
if (oldGroupName.value && oldGroupName.value !== newName) { if (oldGroupName.value && oldGroupName.value !== newName) {
// Sync in members // Sync in members
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m) groupInfo().members = groupInfo().members.map(m => m === oldGroupName.value ? newName : m)
// Notify parent to sync in rules // Notify parent to sync in rules
emit('rename-group', { oldName: oldGroupName.value, newName }) emit('rename-group', { oldName: oldGroupName.value, newName })
} }
group.value.declares[editingGroupIndex.value] = editingGroup.value groupInfo().declares[editingGroupIndex.value] = editingGroup.value
} }
showGroupDialog.value = false showGroupDialog.value = false
} }
@@ -70,7 +81,7 @@ function saveGroup() {
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" /> <Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</div> </div>
<DataTable :value="group.declares" responsiveLayout="scroll"> <DataTable :value="groupInfo().declares" responsiveLayout="scroll">
<Column field="group_name" :header="t('acl.group.name')" /> <Column field="group_name" :header="t('acl.group.name')" />
<Column field="group_secret" :header="t('acl.group.secret')"> <Column field="group_secret" :header="t('acl.group.secret')">
<template #body="{ data }"> <template #body="{ data }">
@@ -90,7 +101,7 @@ function saveGroup() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label> <label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter <MultiSelect v-model="members" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.group.members')" /> :placeholder="t('acl.group.members')" />
</div> </div>
@@ -2,7 +2,7 @@
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue' import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Acl, AclAction, AclChainType } from '../../types/network' import { Acl, AclAction, AclChainType, ensureAclV1 } from '../../types/network'
import AclChainEditor from './AclChainEditor.vue' import AclChainEditor from './AclChainEditor.vue'
import AclGroupEditor from './AclGroupEditor.vue' import AclGroupEditor from './AclGroupEditor.vue'
@@ -12,6 +12,7 @@ const { t } = useI18n()
const activeTab = ref(0) const activeTab = ref(0)
const menu = ref() const menu = ref()
const aclV1 = computed(() => ensureAclV1(acl.value))
const addMenuModel = ref([ const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) }, { label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
@@ -20,10 +21,6 @@ const addMenuModel = ref([
]) ])
function addChain(type: AclChainType) { function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = '' let defaultName = ''
switch (type) { switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break; case AclChainType.Inbound: defaultName = 'Inbound'; break;
@@ -31,7 +28,7 @@ function addChain(type: AclChainType) {
case AclChainType.Forward: defaultName = 'Forward'; break; case AclChainType.Forward: defaultName = 'Forward'; break;
} }
acl.value.acl_v1.chains.push({ aclV1.value.chains.push({
name: defaultName, name: defaultName,
chain_type: type, chain_type: type,
description: '', description: '',
@@ -40,21 +37,20 @@ function addChain(type: AclChainType) {
default_action: AclAction.Allow default_action: AclAction.Allow
}) })
activeTab.value = acl.value.acl_v1.chains.length - 1 activeTab.value = aclV1.value.chains.length - 1
} }
function removeChain(index: number) { function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) { if (confirm(t('acl.delete_chain_confirm'))) {
acl.value.acl_v1?.chains.splice(index, 1) aclV1.value.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) { if (activeTab.value >= aclV1.value.chains.length) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0)) activeTab.value = Math.max(0, aclV1.value.chains.length)
} }
} }
} }
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) { function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
if (!acl.value.acl_v1) return aclV1.value.chains.forEach(chain => {
acl.value.acl_v1.chains.forEach(chain => {
chain.rules.forEach(rule => { chain.rules.forEach(rule => {
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g) rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g) rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
@@ -63,11 +59,11 @@ function handleRenameGroup({ oldName, newName }: { oldName: string, newName: str
} }
const groupNames = computed(() => { const groupNames = computed(() => {
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || [] return aclV1.value.group?.declares.map(g => g.group_name) || []
}) })
const tabs = computed(() => { const tabs = computed(() => {
const chains = acl.value.acl_v1?.chains || [] const chains = aclV1.value.chains
const result: { type: string, label: string, index: number }[] = [] const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) { if (chains.length === 0) {
@@ -124,24 +120,13 @@ const tabs = computed(() => {
</div> </div>
<!-- Rule Chains --> <!-- Rule Chains -->
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4"> <div v-if="tab.type === 'chain' && aclV1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" /> <AclChainEditor v-model="aclV1.chains[tab.index]" :group-names="groupNames" />
</div> </div>
<!-- Group Management --> <!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4"> <div v-if="tab.type === 'groups'" class="py-4">
<template v-if="acl.acl_v1"> <AclGroupEditor v-model="aclV1.group" :group-names="groupNames" @rename-group="handleRenameGroup" />
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
@rename-group="handleRenameGroup" />
<div v-else class="flex justify-center p-4">
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
@click="acl.acl_v1.group = { declares: [], members: [] }" />
</div>
</template>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
</div>
</div> </div>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue'; import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { AclAction, AclProtocol, AclRule } from '../../types/network'; import { AclAction, AclProtocol, AclRule, ensureAclRuleLists } from '../../types/network';
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -32,6 +32,8 @@ const showPorts = computed(() => {
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
}) })
watch(() => rule.value, ensureAclRuleLists, { immediate: true })
function close() { function close() {
emit('update:visible', false) emit('update:visible', false)
} }
@@ -341,6 +341,8 @@ web:
import_config: 导入配置 import_config: 导入配置
create_new: 创建新网络 create_new: 创建新网络
network_status: 网络状态 network_status: 网络状态
loading_network_status: 正在加载网络状态
network_info_unavailable: 网络状态不可用
network_configuration: 网络配置 network_configuration: 网络配置
loading_network_configuration: 加载网络配置 loading_network_configuration: 加载网络配置
no_network_selected: 未选择网络 no_network_selected: 未选择网络
@@ -341,6 +341,8 @@ web:
import_config: Import Config import_config: Import Config
create_new: Create New Network create_new: Create New Network
network_status: Network Status network_status: Network Status
loading_network_status: Loading Network Status
network_info_unavailable: Network status is unavailable
network_configuration: Network Configuration network_configuration: Network Configuration
loading_network_configuration: Loading Network Configuration loading_network_configuration: Loading Network Configuration
no_network_selected: No Network Selected no_network_selected: No Network Selected
@@ -0,0 +1,82 @@
import type { PeerRoutePair } from '../types/network'
export function numericValue(value: unknown): number | undefined {
if (typeof value === 'number')
return Number.isFinite(value) ? value : undefined
if (typeof value !== 'string' || value.trim() === '')
return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
export function peerConns(info: PeerRoutePair) {
return info.peer?.conns || []
}
function defaultConnId(info: PeerRoutePair) {
const defaultConn = info.peer?.default_conn_id
if (!defaultConn)
return undefined
const part1 = defaultConn.part1 ?? 0
const part2 = defaultConn.part2 ?? 0
const part3 = defaultConn.part3 ?? 0
const part4 = defaultConn.part4 ?? 0
if (part1 === 0 && part2 === 0 && part3 === 0 && part4 === 0)
return undefined
const toHex = (value: number) => value.toString(16).padStart(8, '0')
const part1Hex = toHex(part1)
const part2Hex = toHex(part2)
const part3Hex = toHex(part3)
const part4Hex = toHex(part4)
return `${part1Hex}-${part2Hex.slice(0, 4)}-${part2Hex.slice(4, 8)}-${part3Hex.slice(0, 4)}-${part3Hex.slice(4, 8)}${part4Hex}`
}
function defaultConnFirst(info: PeerRoutePair) {
const conns = peerConns(info)
const connId = defaultConnId(info)
if (!connId)
return conns
const defaultConn = conns.find(conn => conn.conn_id === connId)
return defaultConn ? [defaultConn, ...conns.filter(conn => conn !== defaultConn)] : conns
}
export function latencyMs(info: PeerRoutePair) {
const connId = defaultConnId(info)
let minLatencyUs: number | undefined
for (const conn of peerConns(info)) {
if (!conn.stats)
continue
const latencyUs = numericValue(conn.stats.latency_us)
if (latencyUs === undefined)
continue
if (connId === conn.conn_id)
return `${Math.ceil(latencyUs / 1000)}ms`
minLatencyUs = Math.min(minLatencyUs ?? latencyUs, latencyUs)
}
if (minLatencyUs === undefined)
return ''
return `${Math.ceil(minLatencyUs / 1000)}ms`
}
export function lossRate(info: PeerRoutePair) {
for (const conn of defaultConnFirst(info)) {
const loss = numericValue(conn.loss_rate)
if (loss === undefined)
continue
return `${Math.round(loss * 100)}%`
}
return ''
}
+27 -17
View File
@@ -1,24 +1,30 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber' import { IPv4, IPv6 } from 'ip-num/IPNumber'
import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network' import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
export function ipv4ToString(ip: Ipv4Addr) { export function ipv4ToString(ip: Ipv4Addr | null | undefined) {
return IPv4.fromNumber(ip.addr).toString() if (!ip) {
return ''
}
return IPv4.fromNumber(ip.addr ?? 0).toString()
} }
export function ipv4InetToString(ip: Ipv4Inet | undefined) { export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === undefined) { if (ip?.address === undefined) {
return 'undefined' return 'undefined'
} }
return `${ipv4ToString(ip.address)}/${ip.network_length}` return `${ipv4ToString(ip.address)}/${ip.network_length ?? 0}`
} }
export function ipv6ToString(ip: Ipv6Addr) { export function ipv6ToString(ip: Ipv6Addr | null | undefined) {
if (!ip) {
return ''
}
return IPv6.fromBigInt( return IPv6.fromBigInt(
(BigInt(ip.part1) << BigInt(96)) (BigInt(ip.part1 ?? 0) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64)) + (BigInt(ip.part2 ?? 0) << BigInt(64))
+ (BigInt(ip.part3) << BigInt(32)) + (BigInt(ip.part3 ?? 0) << BigInt(32))
+ BigInt(ip.part4), + BigInt(ip.part4 ?? 0),
) ).toString()
} }
function toHexString(uint64: bigint, padding = 9): string { function toHexString(uint64: bigint, padding = 9): string {
@@ -43,14 +49,17 @@ function uint32ToUuid(part1: number, part2: number, part3: number, part4: number
} }
export interface UUID { export interface UUID {
part1: number; part1?: number;
part2: number; part2?: number;
part3: number; part3?: number;
part4: number; part4?: number;
} }
export function UuidToStr(uuid: UUID): string { export function UuidToStr(uuid: UUID | null | undefined): string {
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4); if (!uuid) {
return '';
}
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
} }
export interface Location { export interface Location {
@@ -71,11 +80,12 @@ export interface DeviceInfo {
} }
export function buildDeviceInfo(device: any): DeviceInfo { export function buildDeviceInfo(device: any): DeviceInfo {
const runningInstances = device.info?.running_network_instances ?? [];
let dev_info: DeviceInfo = { let dev_info: DeviceInfo = {
hostname: device.info?.hostname, hostname: device.info?.hostname,
public_ip: device.client_url, public_ip: device.client_url,
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)), running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length, running_network_count: runningInstances.length,
report_time: device.info?.report_time, report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version, easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id), machine_id: UuidToStr(device.info?.machine_id),
+155 -20
View File
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'
import { import {
NetworkConfig as NetworkConfigPb, NetworkConfig as NetworkConfigPb,
NetworkingMethod, NetworkingMethod,
type NetworkPeerConfig,
type NetworkConfig as ProtoNetworkConfig, type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig, type PortForwardConfig,
} from '../generated/proto/api_manage' } from '../generated/proto/api_manage'
@@ -16,11 +17,16 @@ import {
type GroupInfo, type GroupInfo,
type Rule as AclRule, type Rule as AclRule,
} from '../generated/proto/acl' } from '../generated/proto/acl'
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common' import {
CompressionAlgoPb,
NatType,
type PeerFeatureFlag,
type SecureModeConfig,
} from '../generated/proto/common'
import { prepareNetworkConfigForProtoJson } from './networkCompat' import { prepareNetworkConfigForProtoJson } from './networkCompat'
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod } export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig } export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig }
export type NetworkConfig = Omit< export type NetworkConfig = Omit<
ProtoNetworkConfig, ProtoNetworkConfig,
@@ -32,14 +38,39 @@ export type NetworkConfig = Omit<
networking_method: NetworkingMethod | string networking_method: NetworkingMethod | string
} }
export type NormalizedAclV1 = AclV1 & {
group: GroupInfo
}
const UINT64_MAX = (1n << 64n) - 1n const UINT64_MAX = (1n << 64n) - 1n
interface NetworkingConfigFields { interface NetworkingConfigFields {
peer_urls: string[] peer_urls: string[]
peers?: NetworkPeerConfig[]
public_server_url?: string public_server_url?: string
networking_method?: NetworkingMethod | string networking_method?: NetworkingMethod | string
} }
interface NetworkingMethodOptions {
fillPeerUrlsFromPeers?: boolean
}
function emptyGroupInfo(): GroupInfo {
return {
declares: [],
members: [],
}
}
function emptyAcl(): Acl {
return {
acl_v1: {
group: emptyGroupInfo(),
chains: [],
},
}
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig { export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
return { return {
...NetworkConfigPb.create(), ...NetworkConfigPb.create(),
@@ -110,15 +141,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_magic_dns: false, enable_magic_dns: false,
enable_private_mode: false, enable_private_mode: false,
port_forwards: [], port_forwards: [],
acl: { acl: emptyAcl(),
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
} }
} }
@@ -126,6 +149,88 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0) return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
} }
function cleanNetworkPeers(peers: NetworkPeerConfig[] | undefined): NetworkPeerConfig[] {
return (peers ?? [])
.map((peer) => ({
...peer,
uri: peer.uri.trim(),
}))
.filter((peer) => peer.uri.length > 0)
}
function peersFromUrls(urls: string[], existingPeers: NetworkPeerConfig[]): NetworkPeerConfig[] {
const peersByUri = new Map<string, NetworkPeerConfig>()
for (const peer of existingPeers) {
if (!peersByUri.has(peer.uri)) {
peersByUri.set(peer.uri, peer)
}
}
return urls.map((uri) => ({
...(peersByUri.get(uri) ?? {}),
uri,
}))
}
export function ensureAclRuleLists(rule: AclRule): AclRule {
rule.ports ??= []
rule.source_ips ??= []
rule.destination_ips ??= []
rule.source_ports ??= []
rule.source_groups ??= []
rule.destination_groups ??= []
return rule
}
export function ensureAclChain(chain: AclChain): AclChain {
chain.rules ??= []
chain.rules.forEach(ensureAclRuleLists)
return chain
}
export function ensureGroupInfo(group: GroupInfo): GroupInfo {
group.declares ??= []
group.members ??= []
return group
}
export function ensureAclV1(acl: Acl): NormalizedAclV1 {
acl.acl_v1 ??= { chains: [], group: emptyGroupInfo() }
acl.acl_v1.chains ??= []
acl.acl_v1.chains.forEach(ensureAclChain)
acl.acl_v1.group = ensureGroupInfo(acl.acl_v1.group ?? emptyGroupInfo())
return acl.acl_v1 as NormalizedAclV1
}
function normalizeAcl(acl: Acl | undefined): Acl {
const source = acl ?? emptyAcl()
const aclV1 = source.acl_v1 ?? { chains: [], group: emptyGroupInfo() }
return {
...source,
acl_v1: {
...aclV1,
chains: (aclV1.chains ?? []).map((chain) => ({
...chain,
rules: (chain.rules ?? []).map((rule) => ({ ...ensureAclRuleLists({ ...rule }) })),
})),
group: ensureGroupInfo({
...(aclV1.group ?? emptyGroupInfo()),
declares: aclV1.group?.declares ?? [],
members: aclV1.group?.members ?? [],
}),
},
}
}
function isGroupInfoEmpty(group: GroupInfo | undefined): boolean {
return (group?.declares?.length ?? 0) === 0 && (group?.members?.length ?? 0) === 0
}
function isAclEmpty(acl: Acl | undefined): boolean {
const aclV1 = acl?.acl_v1
return !aclV1 || ((aclV1.chains?.length ?? 0) === 0 && isGroupInfoEmpty(aclV1.group))
}
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null { function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
if (v == null) return null if (v == null) return null
@@ -154,15 +259,24 @@ function toBackendUint64(v: number | bigint | string | null | undefined): bigint
} }
} }
function applyNetworkingMethod(config: NetworkingConfigFields): void { function applyNetworkingMethod(
config: NetworkingConfigFields,
options: NetworkingMethodOptions = {},
): void {
const existingPeers = cleanNetworkPeers(config.peers)
config.peer_urls = cleanPeerUrls(config.peer_urls) config.peer_urls = cleanPeerUrls(config.peer_urls)
if (options.fillPeerUrlsFromPeers && config.peer_urls.length === 0 && existingPeers.length > 0) {
config.peer_urls = existingPeers.map((peer) => peer.uri)
}
const publicServerUrl = config.public_server_url?.trim() ?? '' const publicServerUrl = config.public_server_url?.trim() ?? ''
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
switch (networkingMethod) { switch (networkingMethod) {
case NetworkingMethod.PublicServer: case NetworkingMethod.PublicServer:
config.peer_urls = publicServerUrl ? [publicServerUrl] : [] config.peer_urls = publicServerUrl
? [publicServerUrl]
: (options.fillPeerUrlsFromPeers ? existingPeers.map((peer) => peer.uri) : [])
break break
case NetworkingMethod.Manual: case NetworkingMethod.Manual:
break break
@@ -174,6 +288,7 @@ function applyNetworkingMethod(config: NetworkingConfigFields): void {
config.networking_method = NetworkingMethod.Manual config.networking_method = NetworkingMethod.Manual
config.public_server_url = '' config.public_server_url = ''
config.peers = peersFromUrls(config.peer_urls, existingPeers)
} }
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig { export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
@@ -181,11 +296,19 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
ignoreUnknownFields: true, ignoreUnknownFields: true,
}) as unknown as NetworkConfig }) as unknown as NetworkConfig
applyNetworkingMethod(normalized) applyNetworkingMethod(normalized, { fillPeerUrlsFromPeers: true })
normalized.mtu = normalizeNumberForInput(normalized.mtu) normalized.mtu = normalizeNumberForInput(normalized.mtu)
normalized.instance_recv_bps_limit = normalizeUint64ForInput( normalized.instance_recv_bps_limit = normalizeUint64ForInput(
normalized.instance_recv_bps_limit as any, normalized.instance_recv_bps_limit as any,
) )
normalized.proxy_cidrs ??= []
normalized.listener_urls ??= []
normalized.relay_network_whitelist ??= []
normalized.routes ??= []
normalized.exit_nodes ??= []
normalized.mapped_listeners ??= []
normalized.port_forwards ??= []
normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl)
return normalized return normalized
} }
@@ -198,6 +321,9 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
applyNetworkingMethod(backend) applyNetworkingMethod(backend)
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit) backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
if (config.acl === undefined || isAclEmpty(config.acl)) {
backend.acl = undefined
}
return NetworkConfigPb.toJson(backend, { return NetworkConfigPb.toJson(backend, {
useProtoFieldName: true, useProtoFieldName: true,
@@ -286,6 +412,7 @@ export interface Route {
proxy_cidrs: string[] proxy_cidrs: string[]
hostname: string hostname: string
stun_info?: StunInfo stun_info?: StunInfo
feature_flag?: PeerFeatureFlag
inst_id: string inst_id: string
version: string version: string
} }
@@ -293,6 +420,7 @@ export interface Route {
export interface PeerInfo { export interface PeerInfo {
peer_id: number peer_id: number
conns: PeerConnInfo[] conns: PeerConnInfo[]
default_conn_id?: CommonUuid
} }
export interface PeerConnInfo { export interface PeerConnInfo {
@@ -303,7 +431,7 @@ export interface PeerConnInfo {
features: string[] features: string[]
tunnel?: TunnelInfo tunnel?: TunnelInfo
stats?: PeerConnStats stats?: PeerConnStats
loss_rate: number loss_rate?: number | string
} }
export interface PeerRoutePair { export interface PeerRoutePair {
@@ -322,11 +450,18 @@ export interface TunnelInfo {
} }
export interface PeerConnStats { export interface PeerConnStats {
rx_bytes: number rx_bytes: number | string
tx_bytes: number tx_bytes: number | string
rx_packets: number rx_packets: number | string
tx_packets: number tx_packets: number | string
latency_us: number latency_us: number | string
}
export interface CommonUuid {
part1?: number
part2?: number
part3?: number
part4?: number
} }
// 添加新行 // 添加新行
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { latencyMs, lossRate } from '../src/modules/statusDisplay'
import { ipv4ToString, ipv6ToString } from '../src/modules/utils'
function peerRoutePair(conns: any[]) {
return {
route: {
ipv4_addr: '10.0.0.2',
hostname: 'peer',
version: 'test',
},
peer: {
conns,
},
} as any
}
function peerRoutePairWithDefaultConn(conns: any[], defaultConnId: string) {
const [part1, part2, part3, part4] = defaultConnId
.replaceAll('-', '')
.match(/.{8}/g)!
.map((part) => Number.parseInt(part, 16))
return {
...peerRoutePair(conns),
peer: {
default_conn_id: {
part1,
part2,
part3,
part4,
},
conns,
},
} as any
}
describe('status display helpers', () => {
it('does not render missing IP values as zero addresses', () => {
expect(ipv4ToString(undefined)).toBe('')
expect(ipv4ToString(null)).toBe('')
expect(ipv4ToString({} as any)).toBe('0.0.0.0')
expect(ipv4ToString({ addr: 0 })).toBe('0.0.0.0')
expect(ipv6ToString(undefined)).toBe('')
expect(ipv6ToString(null)).toBe('')
expect(ipv6ToString({} as any)).toBe('::0')
expect(ipv6ToString({ part1: 0, part2: 0, part3: 0, part4: 0 })).toBe('::0')
expect(ipv6ToString({ part4: 1 } as any)).toBe('::1')
})
it('skips missing latency and loss values', () => {
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'valid', stats: { latency_us: '2500' } },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('3ms')
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'valid', loss_rate: '0.25' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('25%')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('')
})
it('prefers the default connection when its metric is valid', () => {
const defaultConnId = '00000001-0002-0003-0004-000000000005'
const conns = [
{ conn_id: 'fallback', stats: { latency_us: '1000' }, loss_rate: '0.01' },
{ conn_id: defaultConnId, stats: { latency_us: '9000' }, loss_rate: '0.5' },
]
expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms')
expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%')
})
})
+1 -1
View File
@@ -218,7 +218,7 @@ class WebRemoteClient implements Api.RemoteClient {
} }
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> { async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id); const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id);
return response.info.map[inst_id]; return response.info?.map?.[inst_id];
} }
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> { async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks'); const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
+3
View File
@@ -40,6 +40,9 @@ cli:
geoip_db: geoip_db:
en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb" en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb"
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb" zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
heartbeat_min_response_ms:
en: "Minimum response time for config-server heartbeat RPCs in milliseconds, default is 0"
zh-CN: "配置服务心跳 RPC 的最短响应时间,单位毫秒,默认为 0"
disable_registration: disable_registration:
en: "Disable user registration" en: "Disable user registration"
zh-CN: "禁用用户注册" zh-CN: "禁用用户注册"
File diff suppressed because it is too large Load Diff
+687 -5
View File
@@ -1,3 +1,5 @@
mod managed_config;
mod runtime_reconcile;
pub mod session; pub mod session;
pub mod storage; pub mod storage;
@@ -5,6 +7,7 @@ use std::sync::{
Arc, Arc,
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
}; };
use std::time::Duration;
use dashmap::DashMap; use dashmap::DashMap;
use easytier::{ use easytier::{
@@ -30,6 +33,10 @@ use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
#[include = "geoip2-cn.mmdb"] #[include = "geoip2-cn.mmdb"]
struct GeoipDb; struct GeoipDb;
pub fn is_managed_config_revision_conflict(error: &anyhow::Error) -> bool {
managed_config::is_revision_conflict(error)
}
fn load_geoip_db(geoip_db: Option<String>) -> Option<maxminddb::Reader<Vec<u8>>> { fn load_geoip_db(geoip_db: Option<String>) -> Option<maxminddb::Reader<Vec<u8>>> {
if let Some(path) = geoip_db { if let Some(path) = geoip_db {
match maxminddb::Reader::open_readfile(&path) { match maxminddb::Reader::open_readfile(&path) {
@@ -63,12 +70,14 @@ pub struct ClientManager {
webhook_config: SharedWebhookConfig, webhook_config: SharedWebhookConfig,
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>, geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
heartbeat_min_response_delay: Duration,
} }
impl ClientManager { impl ClientManager {
pub fn new( pub fn new(
db: Db, db: Db,
geoip_db: Option<String>, geoip_db: Option<String>,
heartbeat_min_response_delay: Duration,
feature_flags: Arc<FeatureFlags>, feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig, webhook_config: SharedWebhookConfig,
) -> Self { ) -> Self {
@@ -92,6 +101,7 @@ impl ClientManager {
webhook_config, webhook_config,
geoip_db: Arc::new(load_geoip_db(geoip_db)), geoip_db: Arc::new(load_geoip_db(geoip_db)),
heartbeat_min_response_delay,
} }
} }
@@ -105,6 +115,7 @@ impl ClientManager {
let storage = self.storage.weak_ref(); let storage = self.storage.weak_ref();
let listeners_cnt = self.listeners_cnt.clone(); let listeners_cnt = self.listeners_cnt.clone();
let geoip_db = self.geoip_db.clone(); let geoip_db = self.geoip_db.clone();
let heartbeat_min_response_delay = self.heartbeat_min_response_delay;
let feature_flags = self.feature_flags.clone(); let feature_flags = self.feature_flags.clone();
let webhook_config = self.webhook_config.clone(); let webhook_config = self.webhook_config.clone();
self.tasks.spawn(async move { self.tasks.spawn(async move {
@@ -129,6 +140,7 @@ impl ClientManager {
storage.clone(), storage.clone(),
client_url.clone(), client_url.clone(),
location, location,
heartbeat_min_response_delay,
feature_flags.clone(), feature_flags.clone(),
webhook_config.clone(), webhook_config.clone(),
); );
@@ -149,6 +161,10 @@ impl ClientManager {
self.storage.list_clients() self.storage.list_clients()
} }
pub async fn list_all_sessions(&self) -> Vec<StorageToken> {
self.storage.list_all_clients()
}
pub fn get_session_by_machine_id( pub fn get_session_by_machine_id(
&self, &self,
user_id: UserIdInDb, user_id: UserIdInDb,
@@ -169,7 +185,7 @@ impl ClientManager {
) -> bool { ) -> bool {
let Some(client_url) = self let Some(client_url) = self
.storage .storage
.get_client_url_by_machine_id(user_id, machine_id) .get_client_url_by_machine_id_with_auth(user_id, machine_id, false)
else { else {
return false; return false;
}; };
@@ -189,14 +205,30 @@ impl ClientManager {
user_id: UserIdInDb, user_id: UserIdInDb,
machine_id: uuid::Uuid, machine_id: uuid::Uuid,
desired_configs: Vec<ManagedNetworkConfig>, desired_configs: Vec<ManagedNetworkConfig>,
config_revision: Option<String>,
expected_config_revision: Option<String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
session::SessionRpcService::reconcile_web_source_configs( let expected_config_revision = match expected_config_revision.as_deref().map(str::trim) {
None => managed_config::ExpectedConfigRevision::Any,
Some("") => managed_config::ExpectedConfigRevision::Exact(None),
Some(revision) => managed_config::ExpectedConfigRevision::Exact(Some(revision)),
};
managed_config::reconcile_web_source_configs(
&self.storage, &self.storage,
user_id, user_id,
machine_id, machine_id,
desired_configs, desired_configs,
config_revision.as_deref(),
expected_config_revision,
) )
.await?; .await?;
if let Some(config_revision) = config_revision
&& let Some(session) = self.get_session_by_machine_id(user_id, &machine_id)
{
session
.notify_config_revision_changed(user_id, machine_id, config_revision)
.await;
}
Ok(()) Ok(())
} }
@@ -331,19 +363,449 @@ impl
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{sync::Arc, time::Duration}; use std::{
collections::VecDeque,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration,
};
use axum::{Json, Router, extract::State, routing::post};
use easytier::{ use easytier::{
common::MachineIdOptions,
instance_manager::NetworkInstanceManager, instance_manager::NetworkInstanceManager,
proto::{
api::manage::{NetworkConfig, NetworkingMethod, PortForwardConfig},
common::CompressionAlgoPb,
},
rpc_service::remote_client::Storage as RemoteStorage,
tunnel::{ tunnel::{
common::tests::wait_for_condition, common::tests::wait_for_condition,
udp::{UdpTunnelConnector, UdpTunnelListener}, udp::{UdpTunnelConnector, UdpTunnelListener},
}, },
web_client::WebClient, web_client::{WebClient, run_web_client},
}; };
use serde_json::json;
use sqlx::Executor; use sqlx::Executor;
use tokio::net::UdpSocket;
use crate::{FeatureFlags, client_manager::ClientManager, db::Db}; use crate::{
FeatureFlags, client_manager::ClientManager, db::Db, webhook::ManagedNetworkConfig,
};
const MANAGED_CONFIG_TOKEN: &str = "managed-config-token";
#[derive(Debug, Clone)]
struct TestWebhookState {
validate_responses: Arc<tokio::sync::Mutex<VecDeque<bool>>>,
validate_count: Arc<AtomicUsize>,
block_second_validate: Arc<AtomicBool>,
allow_second_validate: Arc<AtomicBool>,
}
impl TestWebhookState {
fn new(validate_responses: impl IntoIterator<Item = bool>) -> Self {
Self {
validate_responses: Arc::new(tokio::sync::Mutex::new(
validate_responses.into_iter().collect(),
)),
validate_count: Arc::new(AtomicUsize::new(0)),
block_second_validate: Arc::new(AtomicBool::new(false)),
allow_second_validate: Arc::new(AtomicBool::new(true)),
}
}
fn with_blocked_second_validate(
validate_responses: impl IntoIterator<Item = bool>,
) -> Self {
let state = Self::new(validate_responses);
state.block_second_validate.store(true, Ordering::Release);
state.allow_second_validate.store(false, Ordering::Release);
state
}
fn allow_second_validate(&self) {
self.allow_second_validate.store(true, Ordering::Release);
}
fn validate_count(&self) -> usize {
self.validate_count.load(Ordering::Acquire)
}
}
async fn validate_token_handler(
State(state): State<TestWebhookState>,
) -> Json<serde_json::Value> {
let count = state.validate_count.fetch_add(1, Ordering::AcqRel) + 1;
if count == 2 && state.block_second_validate.load(Ordering::Acquire) {
while !state.allow_second_validate.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
let valid = state
.validate_responses
.lock()
.await
.pop_front()
.unwrap_or(true);
if !valid {
return Json(json!({ "valid": false }));
}
Json(json!({
"valid": true,
"binding_version": count,
"config_revision": format!("validated-rev-{count}")
}))
}
async fn webhook_ack_handler() -> Json<serde_json::Value> {
Json(json!({}))
}
async fn test_webhook_config() -> (
crate::webhook::SharedWebhookConfig,
tokio::task::JoinHandle<()>,
TestWebhookState,
) {
let state = TestWebhookState::new([true]);
test_webhook_config_with_state(state).await
}
async fn test_webhook_config_with_state(
state: TestWebhookState,
) -> (
crate::webhook::SharedWebhookConfig,
tokio::task::JoinHandle<()>,
TestWebhookState,
) {
let app = Router::new()
.route("/validate-token", post(validate_token_handler))
.route("/webhook/node-connected", post(webhook_ack_handler))
.route("/webhook/node-disconnected", post(webhook_ack_handler))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(
Arc::new(crate::webhook::WebhookConfig::new(
Some(format!("http://{addr}")),
None,
None,
None,
None,
)),
server,
state,
)
}
async fn add_random_udp_listener(mgr: &mut ClientManager) -> std::net::SocketAddr {
let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let addr = socket.local_addr().unwrap();
let listener =
UdpTunnelListener::new_with_socket(format!("udp://{addr}").parse().unwrap(), socket);
mgr.add_listener(listener).await.unwrap();
addr
}
async fn wait_for_validated_user(mgr: &ClientManager, machine_id: uuid::Uuid) -> i32 {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if let Some(token) = mgr.list_sessions().await.into_iter().find(|token| {
token.token == MANAGED_CONFIG_TOKEN && token.machine_id == machine_id
}) {
break token.user_id;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
async fn wait_for_validate_count(state: &TestWebhookState, target: usize) {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if state.validate_count() >= target {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.unwrap();
}
async fn wait_for_session_urls(mgr: &ClientManager) -> Vec<url::Url> {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
let urls = mgr
.client_sessions
.iter()
.map(|entry| entry.key().clone())
.collect::<Vec<_>>();
if !urls.is_empty() {
break urls;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
fn managed_config(
instance_id: uuid::Uuid,
network_config: serde_json::Value,
) -> ManagedNetworkConfig {
ManagedNetworkConfig {
instance_id: instance_id.to_string(),
network_config,
}
}
async fn wait_for_runtime_config(
manager: &NetworkInstanceManager,
inst_id: uuid::Uuid,
predicate: impl Fn(&NetworkConfig) -> bool,
) -> NetworkConfig {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if let Some(config) = manager
.get_instance_config(&inst_id)
.and_then(|config| NetworkConfig::new_from_config(&config).ok())
.filter(|config| predicate(config))
{
break config;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
async fn start_web_client_for_test(
config_server_addr: std::net::SocketAddr,
machine_id: uuid::Uuid,
manager: Arc<NetworkInstanceManager>,
) -> WebClient {
run_web_client(
&format!("udp://{config_server_addr}/{MANAGED_CONFIG_TOKEN}"),
MachineIdOptions {
explicit_machine_id: Some(machine_id.to_string()),
state_dir: None,
},
Some("managed-config-core".to_string()),
false,
manager,
None,
)
.await
.unwrap()
}
async fn clear_managed_config_db(
mgr: &ClientManager,
user_id: i32,
machine_id: uuid::Uuid,
instance_id: uuid::Uuid,
) {
mgr.db()
.delete_web_network_configs((user_id, machine_id), &[instance_id])
.await
.unwrap();
sqlx::query("DELETE FROM managed_config_revisions WHERE user_id = ? AND device_id = ?")
.bind(user_id)
.bind(machine_id.to_string())
.execute(&mgr.db().inner())
.await
.unwrap();
}
fn assert_updated_runtime_config(updated: &NetworkConfig, instance_id: uuid::Uuid) {
assert_eq!(
updated.instance_id.as_deref(),
Some(instance_id.to_string().as_str())
);
assert_eq!(updated.dhcp, Some(false));
assert_eq!(updated.virtual_ipv4.as_deref(), Some("10.88.0.7"));
assert_eq!(updated.network_length, Some(24));
assert_eq!(updated.hostname.as_deref(), Some("managed-updated-host"));
assert_eq!(updated.network_name.as_deref(), Some("managed-updated"));
assert_eq!(updated.network_secret.as_deref(), Some("secret-updated"));
assert_eq!(
updated.networking_method,
Some(NetworkingMethod::Manual as i32)
);
assert_eq!(updated.peer_urls, vec!["tcp://127.0.0.1:11010".to_string()]);
assert_eq!(
updated.proxy_cidrs,
vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string()
]
);
assert_eq!(updated.no_tun, Some(true));
assert_eq!(updated.disable_ipv6, Some(true));
assert_eq!(updated.enable_kcp_proxy, Some(true));
assert_eq!(updated.disable_kcp_input, Some(true));
assert_eq!(updated.enable_quic_proxy, Some(true));
assert_eq!(updated.disable_quic_input, Some(true));
assert_eq!(updated.disable_p2p, Some(true));
assert_eq!(updated.p2p_only, Some(true));
assert_eq!(updated.lazy_p2p, Some(true));
assert_eq!(updated.relay_all_peer_rpc, Some(true));
assert_eq!(updated.need_p2p, Some(true));
assert_eq!(updated.multi_thread, Some(false));
assert_eq!(updated.proxy_forward_by_system, Some(true));
assert_eq!(updated.disable_encryption, Some(true));
assert_eq!(updated.enable_relay_network_whitelist, Some(true));
assert_eq!(
updated.relay_network_whitelist,
vec!["10.44.0.0/24".to_string(), "10.45.0.0/24".to_string()]
);
assert_eq!(updated.enable_manual_routes, Some(true));
assert_eq!(
updated.routes,
vec!["10.60.0.0/16".to_string(), "10.61.0.0/16".to_string()]
);
assert_eq!(updated.port_forwards[0].bind_ip, "127.0.0.1");
assert_eq!(updated.port_forwards[0].bind_port, 0);
assert_eq!(updated.port_forwards[0].dst_ip, "10.88.0.8");
assert_eq!(updated.port_forwards[0].dst_port, 80);
assert_eq!(updated.port_forwards[0].proto, "tcp");
assert_eq!(updated.disable_udp_hole_punching, Some(true));
assert_eq!(updated.disable_tcp_hole_punching, Some(true));
assert_eq!(updated.disable_sym_hole_punching, Some(true));
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.enable_private_mode, Some(true));
assert_eq!(updated.mtu, Some(1360));
assert_eq!(
updated.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
assert_eq!(updated.encryption_algorithm.as_deref(), Some("xor"));
assert_eq!(updated.instance_recv_bps_limit, Some(123456));
assert_eq!(updated.enable_udp_broadcast_relay, Some(true));
assert_eq!(updated.socket_mark, Some(0));
}
fn initial_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
json!({
"instance_id": inst_id.to_string(),
"dhcp": true,
"network_name": "managed-initial",
"network_secret": "secret-initial",
"networking_method": "Standalone",
"no_tun": true,
"disable_ipv6": true,
"enable_kcp_proxy": false,
"disable_kcp_input": false,
"relay_all_peer_rpc": false,
"multi_thread": false,
"disable_relay_data": false,
"mtu": 1380
})
}
fn updated_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
serde_json::to_value(NetworkConfig {
instance_id: Some(inst_id.to_string()),
dhcp: Some(false),
virtual_ipv4: Some("10.88.0.7".to_string()),
network_length: Some(24),
hostname: Some("managed-updated-host".to_string()),
network_name: Some("managed-updated".to_string()),
network_secret: Some("secret-updated".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
peer_urls: vec!["tcp://127.0.0.1:11010".to_string()],
proxy_cidrs: vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string(),
],
no_tun: Some(true),
disable_ipv6: Some(true),
enable_kcp_proxy: Some(true),
disable_kcp_input: Some(true),
enable_quic_proxy: Some(true),
disable_quic_input: Some(true),
disable_p2p: Some(true),
p2p_only: Some(true),
lazy_p2p: Some(true),
relay_all_peer_rpc: Some(true),
need_p2p: Some(true),
multi_thread: Some(false),
proxy_forward_by_system: Some(true),
disable_encryption: Some(true),
enable_relay_network_whitelist: Some(true),
relay_network_whitelist: vec!["10.44.0.0/24".to_string(), "10.45.0.0/24".to_string()],
enable_manual_routes: Some(true),
routes: vec!["10.60.0.0/16".to_string(), "10.61.0.0/16".to_string()],
port_forwards: vec![PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port: 0,
dst_ip: "10.88.0.8".to_string(),
dst_port: 80,
proto: "tcp".to_string(),
}],
disable_udp_hole_punching: Some(true),
disable_tcp_hole_punching: Some(true),
disable_sym_hole_punching: Some(true),
disable_upnp: Some(true),
disable_relay_data: Some(true),
enable_magic_dns: Some(true),
enable_private_mode: Some(true),
mtu: Some(1360),
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
encryption_algorithm: Some("xor".to_string()),
instance_recv_bps_limit: Some(123456),
enable_udp_broadcast_relay: Some(true),
socket_mark: Some(0),
..Default::default()
})
.unwrap()
}
fn redelivered_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
serde_json::to_value(NetworkConfig {
instance_id: Some(inst_id.to_string()),
dhcp: Some(false),
virtual_ipv4: Some("10.88.0.7".to_string()),
network_length: Some(24),
hostname: Some("managed-redelivered-host".to_string()),
network_name: Some("managed-redelivered".to_string()),
network_secret: Some("secret-updated".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
peer_urls: vec!["tcp://127.0.0.1:11010".to_string()],
proxy_cidrs: vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string(),
],
no_tun: Some(true),
disable_ipv6: Some(true),
enable_kcp_proxy: Some(true),
disable_kcp_input: Some(true),
relay_all_peer_rpc: Some(true),
need_p2p: Some(true),
multi_thread: Some(false),
enable_private_mode: Some(true),
mtu: Some(1360),
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
encryption_algorithm: Some("xor".to_string()),
instance_recv_bps_limit: Some(654321),
..Default::default()
})
.unwrap()
}
#[tokio::test] #[tokio::test]
async fn test_client() { async fn test_client() {
@@ -351,6 +813,7 @@ mod tests {
let mut mgr = ClientManager::new( let mut mgr = ClientManager::new(
Db::memory_db().await, Db::memory_db().await,
None, None,
Duration::ZERO,
Arc::new(FeatureFlags::default()), Arc::new(FeatureFlags::default()),
Arc::new(crate::webhook::WebhookConfig::new( Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None, None, None, None, None, None,
@@ -410,4 +873,223 @@ mod tests {
println!("{:?}", req); println!("{:?}", req);
println!("{:?}", mgr); println!("{:?}", mgr);
} }
#[tokio::test]
async fn managed_web_config_revision_updates_running_core_config() {
let (webhook_config, webhook_server, _) = test_webhook_config().await;
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Duration::ZERO,
Arc::new(FeatureFlags::default()),
webhook_config,
);
let config_server_addr = add_random_udp_listener(&mut mgr).await;
let machine_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4();
let core_manager = Arc::new(NetworkInstanceManager::new());
let client =
start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await;
let user_id = wait_for_validated_user(&mgr, machine_id).await;
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
initial_managed_network_config(instance_id),
)],
Some("rev-initial".to_string()),
None,
)
.await
.unwrap();
wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-initial")
})
.await;
// Online revision update: web-owned running config is fully overwritten
// when non-hot-patch flags such as enable_kcp_proxy change.
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
updated_managed_network_config(instance_id),
)],
Some("rev-updated".to_string()),
Some("rev-initial".to_string()),
)
.await
.unwrap();
let updated = wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-updated")
&& config.enable_kcp_proxy == Some(true)
&& config.port_forwards.len() == 1
})
.await;
assert_updated_runtime_config(&updated, instance_id);
assert_eq!(
core_manager.get_instance_network_config_source(&instance_id),
Some(easytier::common::config::ConfigSource::Web)
);
assert_eq!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.as_deref(),
Some("rev-updated")
);
// Web DB loss path: clear web-owned config and revision, then simulate
// the webhook re-posting the authoritative desired config. The already
// connected session should receive the distinguishable re-delivered
// revision without restarting.
clear_managed_config_db(&mgr, user_id, machine_id, instance_id).await;
assert!(
mgr.db()
.get_network_config((user_id, machine_id), &instance_id.to_string())
.await
.unwrap()
.is_none()
);
assert!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.is_none()
);
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
redelivered_managed_network_config(instance_id),
)],
Some("rev-webhook-redelivery".to_string()),
None,
)
.await
.unwrap();
let redelivered = wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-redelivered")
&& config.instance_recv_bps_limit == Some(654321)
})
.await;
assert_eq!(
redelivered.instance_id.as_deref(),
Some(instance_id.to_string().as_str())
);
assert_eq!(
redelivered.hostname.as_deref(),
Some("managed-redelivered-host")
);
assert_eq!(
redelivered.network_name.as_deref(),
Some("managed-redelivered")
);
assert_eq!(redelivered.enable_kcp_proxy, Some(true));
assert_eq!(redelivered.instance_recv_bps_limit, Some(654321));
assert_eq!(
core_manager.get_instance_network_config_source(&instance_id),
Some(easytier::common::config::ConfigSource::Web)
);
assert_eq!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.as_deref(),
Some("rev-webhook-redelivery")
);
// Reconnect path: a fresh core manager has no local runtime state, so
// the new session must replay the managed config persisted in web DB.
drop(client);
let reconnected_core_manager = Arc::new(NetworkInstanceManager::new());
let _reconnected_client = start_web_client_for_test(
config_server_addr,
machine_id,
reconnected_core_manager.clone(),
)
.await;
wait_for_validated_user(&mgr, machine_id).await;
let replayed = wait_for_runtime_config(&reconnected_core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-redelivered")
&& config.instance_recv_bps_limit == Some(654321)
})
.await;
assert_eq!(
replayed.network_name.as_deref(),
Some("managed-redelivered")
);
assert_eq!(replayed.enable_kcp_proxy, Some(true));
assert_eq!(replayed.instance_recv_bps_limit, Some(654321));
webhook_server.abort();
}
#[tokio::test]
async fn webhook_reject_disconnects_and_revalidates_after_reconnect() {
let webhook_state = TestWebhookState::with_blocked_second_validate([false, true]);
let (webhook_config, webhook_server, webhook_state) =
test_webhook_config_with_state(webhook_state).await;
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Duration::ZERO,
Arc::new(FeatureFlags::default()),
webhook_config,
);
let config_server_addr = add_random_udp_listener(&mut mgr).await;
let machine_id = uuid::Uuid::new_v4();
let core_manager = Arc::new(NetworkInstanceManager::new());
let client =
start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await;
let first_session_urls = wait_for_session_urls(&mgr).await;
wait_for_validate_count(&webhook_state, 1).await;
wait_for_validate_count(&webhook_state, 2).await;
assert!(
mgr.list_sessions().await.is_empty(),
"invalid validate-token response must not authorize the session"
);
webhook_state.allow_second_validate();
let user_id = wait_for_validated_user(&mgr, machine_id).await;
tokio::time::timeout(Duration::from_secs(12), async {
loop {
let reconnected = mgr
.client_sessions
.iter()
.any(|entry| !first_session_urls.iter().any(|url| url == entry.key()));
if reconnected {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap();
assert!(
client.is_connected(),
"web client should reconnect after invalid session heartbeat failure"
);
assert!(webhook_state.validate_count() >= 2);
assert!(
mgr.get_session_by_machine_id(user_id, &machine_id)
.is_some()
);
webhook_server.abort();
}
} }
@@ -0,0 +1,812 @@
use anyhow::Context as _;
use easytier::{
common::config::{
ConfigLoader, EncryptionAlgorithm, PortForwardConfig as RuntimePortForwardConfig,
},
proto::{
acl::Acl,
api::{
config::{
AclPatch, ConfigPatchAction, InstanceConfigPatch, PatchConfigRequest,
PortForwardPatch, ProxyNetworkPatch,
},
instance::{InstanceIdentifier, instance_identifier},
manage::{
ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest, NetworkConfig,
RunNetworkInstanceRequest,
},
},
common::{CompressionAlgoPb, Ipv4Inet as RpcIpv4Inet},
rpc_types::controller::BaseController,
},
};
use super::session::{SessionConfigClient, SessionRpcClient};
pub(super) enum RuntimeReconcileAction {
None,
Run {
config: Box<NetworkConfig>,
overwrite: bool,
},
Patch(Box<InstanceConfigPatch>),
}
#[derive(Clone, PartialEq)]
struct RuntimeProxyNetwork {
cidr: String,
mapped_cidr: Option<String>,
}
fn instance_identifier(inst_id: &str) -> anyhow::Result<InstanceIdentifier> {
let inst_id = uuid::Uuid::parse_str(inst_id)
.with_context(|| format!("invalid runtime instance id: {inst_id}"))?;
Ok(InstanceIdentifier {
selector: Some(instance_identifier::Selector::Id(inst_id.into())),
})
}
fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result<NetworkConfig> {
let data_compress_algo = normalized_data_compress_algo(config.data_compress_algo);
let encryption_algorithm = normalized_encryption_algorithm(config.encryption_algorithm.clone());
let mut config = NetworkConfig::new_from_config(config.gen_config()?)?;
let is_credential_mode = config.network_secret.is_none()
&& config
.secure_mode
.as_ref()
.and_then(|mode| mode.local_private_key.as_deref())
.is_some_and(|key| !key.is_empty());
config.acl = None;
config.port_forwards.clear();
config.proxy_cidrs.clear();
config.disable_relay_data = None;
if config.dhcp.unwrap_or_default() {
config.virtual_ipv4 = None;
config.network_length = None;
}
if let Some(secure_mode) = config.secure_mode.as_mut() {
if !is_credential_mode {
secure_mode.local_private_key = None;
}
secure_mode.local_public_key = None;
}
config.data_compress_algo = data_compress_algo;
config.encryption_algorithm = encryption_algorithm;
Ok(config)
}
fn normalized_data_compress_algo(algo: Option<i32>) -> Option<i32> {
let default = CompressionAlgoPb::None as i32;
let effective = algo.map(|algo| if algo < default { default } else { algo });
effective.filter(|algo| *algo != default)
}
fn normalized_encryption_algorithm(algo: Option<String>) -> Option<String> {
let default = EncryptionAlgorithm::default().to_string();
algo.filter(|algo| algo != &default)
}
fn diff_port_forwards(
current: &[RuntimePortForwardConfig],
desired: &[RuntimePortForwardConfig],
) -> Vec<PortForwardPatch> {
let mut patches = Vec::new();
for cfg in unique_port_forwards(current, desired) {
let current_count = current.iter().filter(|item| *item == &cfg).count();
let desired_count = desired.iter().filter(|item| *item == &cfg).count();
if current_count == desired_count {
continue;
}
if current_count > 0 {
patches.push(PortForwardPatch {
action: ConfigPatchAction::Remove as i32,
cfg: Some(cfg.clone().into()),
});
}
patches.extend((0..desired_count).map(|_| PortForwardPatch {
action: ConfigPatchAction::Add as i32,
cfg: Some(cfg.clone().into()),
}));
}
patches
}
fn unique_port_forwards(
current: &[RuntimePortForwardConfig],
desired: &[RuntimePortForwardConfig],
) -> Vec<RuntimePortForwardConfig> {
let mut unique = Vec::new();
for cfg in current.iter().chain(desired.iter()) {
if !unique.contains(cfg) {
unique.push(cfg.clone());
}
}
unique
}
fn parse_rpc_ipv4_inet(value: &str) -> anyhow::Result<RpcIpv4Inet> {
value
.parse::<RpcIpv4Inet>()
.with_context(|| format!("failed to parse runtime ipv4 cidr: {value}"))
}
fn diff_proxy_networks(
current: &[RuntimeProxyNetwork],
desired: &[RuntimeProxyNetwork],
) -> anyhow::Result<Vec<ProxyNetworkPatch>> {
if current == desired {
return Ok(Vec::new());
}
let mut patches = vec![ProxyNetworkPatch {
action: ConfigPatchAction::Clear as i32,
cidr: Some(clear_proxy_network_cidr(current, desired)?),
..Default::default()
}];
for proxy_network in desired {
patches.push(ProxyNetworkPatch {
action: ConfigPatchAction::Add as i32,
cidr: Some(parse_rpc_ipv4_inet(&proxy_network.cidr)?),
mapped_cidr: proxy_network
.mapped_cidr
.as_deref()
.map(parse_rpc_ipv4_inet)
.transpose()?,
});
}
Ok(patches)
}
fn clear_proxy_network_cidr(
current: &[RuntimeProxyNetwork],
desired: &[RuntimeProxyNetwork],
) -> anyhow::Result<RpcIpv4Inet> {
let cidr = desired
.first()
.or_else(|| current.first())
.map(|proxy_network| proxy_network.cidr.as_str())
.unwrap_or("0.0.0.0/0");
parse_rpc_ipv4_inet(cidr)
}
fn normalized_acl(acl: &Option<Acl>) -> Option<Acl> {
let acl = acl.clone().unwrap_or_default();
(acl != Acl::default()).then_some(acl)
}
fn normalized_port_forwards(
config: &NetworkConfig,
) -> anyhow::Result<Vec<RuntimePortForwardConfig>> {
Ok(config
.gen_config()?
.get_port_forwards()
.into_iter()
.map(|cfg| {
RuntimePortForwardConfig::from(easytier::proto::common::PortForwardConfigPb::from(cfg))
})
.collect())
}
fn normalized_proxy_networks(config: &NetworkConfig) -> anyhow::Result<Vec<RuntimeProxyNetwork>> {
Ok(config
.gen_config()?
.get_proxy_cidrs()
.into_iter()
.map(|proxy_network| RuntimeProxyNetwork {
cidr: proxy_network.cidr.to_string(),
mapped_cidr: proxy_network.mapped_cidr.map(|cidr| cidr.to_string()),
})
.collect())
}
fn normalized_disable_relay_data(config: &NetworkConfig) -> anyhow::Result<bool> {
Ok(config.gen_config()?.get_flags().disable_relay_data)
}
fn web_source_runtime_patch(
current: &NetworkConfig,
desired: &NetworkConfig,
) -> anyhow::Result<Option<InstanceConfigPatch>> {
if let Some(desired_hostname) = desired
.hostname
.as_deref()
.filter(|hostname| !hostname.is_empty())
&& current.hostname.as_deref() != Some(desired_hostname)
{
return Ok(None);
}
let mut current_base = hot_patch_base(current)?;
let mut desired_base = hot_patch_base(desired)?;
current_base.hostname = None;
desired_base.hostname = None;
if current_base != desired_base {
return Ok(None);
}
let mut patch = InstanceConfigPatch::default();
let current_acl = normalized_acl(&current.acl);
let desired_acl = normalized_acl(&desired.acl);
if current_acl != desired_acl {
patch.acl = Some(AclPatch {
acl: Some(desired_acl.unwrap_or_default()),
..Default::default()
});
}
let current_port_forwards = normalized_port_forwards(current)?;
let desired_port_forwards = normalized_port_forwards(desired)?;
if current_port_forwards != desired_port_forwards {
patch.port_forwards = diff_port_forwards(&current_port_forwards, &desired_port_forwards);
}
let current_proxy_networks = normalized_proxy_networks(current)?;
let desired_proxy_networks = normalized_proxy_networks(desired)?;
if current_proxy_networks != desired_proxy_networks {
if current_proxy_networks.is_empty() {
return Ok(None);
}
patch.proxy_networks =
diff_proxy_networks(&current_proxy_networks, &desired_proxy_networks)?;
}
let current_disable_relay_data = normalized_disable_relay_data(current)?;
let desired_disable_relay_data = normalized_disable_relay_data(desired)?;
if current_disable_relay_data != desired_disable_relay_data {
patch.disable_relay_data = Some(desired_disable_relay_data);
}
Ok(Some(patch))
}
fn ensure_runtime_config_converged(
current: &NetworkConfig,
desired: &NetworkConfig,
) -> anyhow::Result<()> {
let patch = web_source_runtime_patch(current, desired)?;
match patch {
Some(patch) if patch == InstanceConfigPatch::default() => Ok(()),
Some(patch) => anyhow::bail!("runtime config still needs patch after reconcile: {patch:?}"),
None => anyhow::bail!("runtime config still needs full overwrite after reconcile"),
}
}
async fn run_web_source_instance(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
config: NetworkConfig,
overwrite: bool,
) -> anyhow::Result<()> {
rpc_client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(inst_id.to_string().into()),
config: Some(config),
overwrite,
source: RpcConfigSource::Web as i32,
},
)
.await?;
Ok(())
}
pub(super) async fn get_runtime_config(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
) -> anyhow::Result<NetworkConfig> {
rpc_client
.get_network_instance_config(
BaseController::default(),
GetNetworkInstanceConfigRequest {
inst_id: Some(inst_id.to_string().into()),
},
)
.await?
.config
.ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}"))
}
pub(super) async fn prepare_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
desired_config: NetworkConfig,
is_running: bool,
) -> anyhow::Result<RuntimeReconcileAction> {
if !is_running {
return Ok(RuntimeReconcileAction::Run {
config: Box::new(desired_config),
overwrite: false,
});
}
let current_config = get_runtime_config(rpc_client, inst_id).await?;
prepare_web_source_runtime_reconcile_from_current(&current_config, desired_config)
}
pub(super) fn prepare_web_source_runtime_reconcile_from_current(
current_config: &NetworkConfig,
desired_config: NetworkConfig,
) -> anyhow::Result<RuntimeReconcileAction> {
let Some(patch) = web_source_runtime_patch(current_config, &desired_config)? else {
return Ok(RuntimeReconcileAction::Run {
config: Box::new(desired_config),
overwrite: true,
});
};
if patch == InstanceConfigPatch::default() {
return Ok(RuntimeReconcileAction::None);
}
Ok(RuntimeReconcileAction::Patch(Box::new(patch)))
}
pub(super) async fn apply_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
inst_id: &str,
desired_config: NetworkConfig,
action: RuntimeReconcileAction,
) -> anyhow::Result<NetworkConfig> {
match action {
RuntimeReconcileAction::None => Ok(desired_config),
RuntimeReconcileAction::Run { config, overwrite } => {
run_web_source_instance(rpc_client, inst_id, *config, overwrite).await?;
Ok(desired_config)
}
RuntimeReconcileAction::Patch(patch) => {
config_client
.patch_config(
BaseController::default(),
PatchConfigRequest {
instance: Some(instance_identifier(inst_id)?),
patch: Some(*patch),
},
)
.await?;
let current_config = get_runtime_config(rpc_client, inst_id).await?;
ensure_runtime_config_converged(&current_config, &desired_config)?;
Ok(current_config)
}
}
}
#[cfg(test)]
mod tests {
use easytier::proto::{
api::{
config::ConfigPatchAction,
manage::{NetworkingMethod, PortForwardConfig},
},
common::{CompressionAlgoPb, SocketType},
};
use super::*;
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
NetworkConfig {
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
dhcp: Some(true),
network_name: Some("managed".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
port_forwards,
..Default::default()
}
}
fn port_forward(bind_port: u32, dst_port: u32) -> PortForwardConfig {
PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port,
dst_ip: "10.144.0.1".to_string(),
dst_port,
proto: "tcp".to_string(),
}
}
fn patch_port(patch: &PortForwardPatch) -> (i32, u32, u32, i32) {
let cfg = patch.cfg.as_ref().expect("port forward patch cfg");
(
patch.action,
cfg.bind_addr.as_ref().expect("bind addr").port,
cfg.dst_addr.as_ref().expect("dst addr").port,
cfg.socket_type,
)
}
fn patch_proxy_network(patch: &ProxyNetworkPatch) -> (i32, String, Option<String>) {
(
patch.action,
patch.cidr.map(|cidr| cidr.to_string()).unwrap_or_default(),
patch.mapped_cidr.map(|cidr| cidr.to_string()),
)
}
#[test]
fn runtime_patch_ignores_runtime_defaults_and_adds_port_forward() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.virtual_ipv4 = Some("10.144.0.2".to_string());
current.network_length = Some(16);
current.bind_device = Some(true);
current.dev_name = Some(String::new());
current.disable_ipv6 = Some(false);
current.mtu = Some(1380);
current.multi_thread = Some(true);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_removes_deleted_port_forward_without_clear() {
let current =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Remove as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_reconciles_duplicate_port_forward_count() {
let current =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23000, 5174)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 2);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Remove as i32,
23000,
5174,
SocketType::Tcp as i32
)
);
assert_eq!(
patch_port(&patch.port_forwards[1]),
(
ConfigPatchAction::Add as i32,
23000,
5174,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_convergence_rejects_stale_extra_port_forward() {
let current = config_with_port_forwards(vec![
port_forward(23000, 5174),
port_forward(23007, 3389),
port_forward(23100, 8080),
]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let err = ensure_runtime_config_converged(&current, &desired)
.expect_err("extra runtime port forward should not converge");
assert!(
err.to_string()
.contains("runtime config still needs patch after reconcile"),
"unexpected error: {err:?}"
);
}
#[test]
fn runtime_patch_canonicalizes_port_forward_protocol() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired_port_forward = port_forward(23000, 5174);
desired_port_forward.proto = "TCP".to_string();
let desired = config_with_port_forwards(vec![desired_port_forward]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch, InstanceConfigPatch::default());
ensure_runtime_config_converged(&current, &desired).expect("runtime converged");
}
#[test]
fn runtime_patch_rejects_non_hot_config_change() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.network_secret = Some("new-secret".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_routes_change() {
let mut current = config_with_port_forwards(Vec::new());
current.enable_manual_routes = Some(true);
current.routes = vec!["10.1.0.0/16".to_string(), "10.2.0.0/16".to_string()];
let mut desired = config_with_port_forwards(Vec::new());
desired.enable_manual_routes = Some(true);
desired.routes = vec!["10.2.0.0/16".to_string(), "10.3.0.0/16".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_replaces_proxy_networks() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec![
"10.1.0.0/16".to_string(),
"10.2.0.0/16->10.20.0.0/16".to_string(),
];
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec![
"10.2.0.0/16->10.21.0.0/16".to_string(),
"10.3.0.0/16".to_string(),
];
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 3);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.2.0.0/16".to_string(),
None
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[1]),
(
ConfigPatchAction::Add as i32,
"10.2.0.0/16".to_string(),
Some("10.21.0.0/16".to_string())
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[2]),
(
ConfigPatchAction::Add as i32,
"10.3.0.0/16".to_string(),
None
)
);
}
#[test]
fn runtime_patch_replaces_proxy_networks_with_same_source_cidr() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec![
"10.1.2.0/24".to_string(),
"10.1.2.0/24->10.1.3.0/24".to_string(),
];
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24->10.1.3.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 2);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.1.2.0/24".to_string(),
None
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[1]),
(
ConfigPatchAction::Add as i32,
"10.1.2.0/24".to_string(),
Some("10.1.3.0/24".to_string())
)
);
}
#[test]
fn runtime_patch_rejects_proxy_network_empty_to_nonempty() {
let current = config_with_port_forwards(Vec::new());
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_clears_proxy_networks_with_legacy_compatible_cidr() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let desired = config_with_port_forwards(Vec::new());
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 1);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.1.2.0/24".to_string(),
None
)
);
}
#[test]
fn runtime_patch_updates_disable_relay_data() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.disable_relay_data = Some(true);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.disable_relay_data, Some(true));
}
#[test]
fn runtime_patch_still_rejects_unsupported_flag_change() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.no_tun = Some(true);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_encryption_algorithm_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired = current.clone();
desired.encryption_algorithm = Some("managed-test-algo".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_data_compress_algo_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired = current.clone();
desired.data_compress_algo = Some(CompressionAlgoPb::Zstd as i32);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_credential_private_key_change() {
let mut current = config_with_port_forwards(Vec::new());
current.network_secret = None;
current.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("mUuD5fsIm/ftvgS4WBAYFMNLqWX3qT9rnm4PrnOqb9s=".to_string()),
local_public_key: None,
});
let mut desired = current.clone();
desired.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("aEpz80FuYbaY4QLJizAIuIcK4TYsoSA9jHHCXCOQJoc=".to_string()),
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_ignores_generated_secure_key_when_network_secret_exists() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("mUuD5fsIm/ftvgS4WBAYFMNLqWX3qT9rnm4PrnOqb9s=".to_string()),
local_public_key: Some("4x6L5dZjB8hsPO4f96Hyhi4xFealBu6i3BxRVBYR1Fc=".to_string()),
});
let mut desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
desired.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: None,
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_ignores_runtime_hostname_when_desired_omits_hostname() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.hostname = Some("runtime-host".to_string());
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_rejects_explicit_desired_hostname_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
desired.hostname =
Some(easytier::common::config::TomlConfigLoader::default().get_hostname());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,913 @@
use std::collections::{HashMap, HashSet};
use easytier::{
proto::{
api::manage::{
DeleteNetworkInstanceRequest, ListNetworkInstanceMetaRequest,
ListNetworkInstanceRequest, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest,
},
rpc_types::controller::BaseController,
web::HeartbeatRequest,
},
rpc_service::remote_client::{ListNetworkProps, Storage as _},
};
use tokio::sync::{RwLock, broadcast};
use super::{SessionConfigClient, SessionData, SessionRpcClient, SessionRpcService};
use crate::client_manager::{
managed_config::{self, PersistedConfigSource},
runtime_reconcile,
storage::{StorageInner, WeakRefStorage},
};
async fn recv_latest_heartbeat(
heartbeat_waiter: &mut broadcast::Receiver<HeartbeatRequest>,
) -> Option<HeartbeatRequest> {
let mut req = loop {
match heartbeat_waiter.recv().await {
Ok(req) => break req,
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"heartbeat reconcile worker lagged, waiting for latest request"
);
}
Err(broadcast::error::RecvError::Closed) => {
tracing::error!("Failed to receive heartbeat request: channel closed");
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,
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => return None,
}
}
Some(req)
}
pub(super) async fn reconcile_network_configs_on_heartbeat(
session_data: std::sync::Weak<RwLock<SessionData>>,
mut heartbeat_waiter: broadcast::Receiver<HeartbeatRequest>,
storage: WeakRefStorage,
mut rpc_client: SessionRpcClient,
mut config_client: SessionConfigClient,
) {
let mut cache = ReconcileCache::default();
loop {
let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else {
return;
};
let Some(storage) = storage.upgrade() else {
tracing::error!("Failed to get storage");
return;
};
let mut round =
match prepare_reconcile_round(&session_data, &storage, &mut rpc_client, req).await {
RoundStatus::Ready(round) => round,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
let running_metas =
match sync_running_sources_for_round(&mut rpc_client, &storage, &mut round).await {
RoundStatus::Ready(running_metas) => running_metas,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
let desired_web_inst_ids =
managed_config::desired_web_source_instance_ids(&round.local_configs);
cache.runtime_configs.retain_desired(&desired_web_inst_ids);
let mut outcome = match cleanup_stale_web_source_instances(
&session_data,
&storage,
&mut rpc_client,
&round,
running_metas.as_deref(),
&desired_web_inst_ids,
&mut cache,
)
.await
{
RoundStatus::Ready(outcome) => outcome,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
outcome.merge(
reconcile_desired_runtime_configs(
&session_data,
&mut rpc_client,
&mut config_client,
&round,
&mut cache,
)
.await,
);
if !outcome.has_failed {
cache.last_desired_web_inst_ids = Some(desired_web_inst_ids);
}
match mark_config_revision_applied_if_current(&session_data, &storage, &round, &outcome)
.await
{
RoundStatus::Ready(()) | RoundStatus::Skip => {}
RoundStatus::Stop => return,
}
}
}
enum RoundStatus<T> {
Ready(T),
Skip,
Stop,
}
enum ConfigActionResult {
Success,
Failed,
StopRound,
}
#[derive(Default)]
struct ReconcileCache {
cleaned_web_source_instances: bool,
last_desired_web_inst_ids: Option<HashSet<String>>,
runtime_configs: SessionRuntimeConfigCache,
}
#[derive(Default)]
struct SessionRuntimeConfigCache {
entries: HashMap<String, NetworkConfig>,
}
impl SessionRuntimeConfigCache {
fn plan(
&self,
inst_id: &str,
desired_config: NetworkConfig,
) -> anyhow::Result<Option<runtime_reconcile::RuntimeReconcileAction>> {
let Some(observed_config) = self.entries.get(inst_id) else {
return Ok(None);
};
runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
observed_config,
desired_config,
)
.map(Some)
}
fn remember(&mut self, inst_id: &str, observed_config: NetworkConfig) {
self.entries.insert(inst_id.to_string(), observed_config);
}
fn forget(&mut self, inst_id: &str) {
self.entries.remove(inst_id);
}
fn forget_many<'a>(&mut self, inst_ids: impl IntoIterator<Item = &'a String>) {
for inst_id in inst_ids {
self.entries.remove(inst_id);
}
}
fn retain_desired(&mut self, desired_web_inst_ids: &HashSet<String>) {
self.entries
.retain(|inst_id, _| desired_web_inst_ids.contains(inst_id));
}
}
#[derive(Default)]
struct ReconcileOutcome {
has_failed: bool,
managed_revision_failed: bool,
}
impl ReconcileOutcome {
fn record_failure(&mut self, managed_revision_failed: bool) {
self.has_failed = true;
self.managed_revision_failed |= managed_revision_failed;
}
fn merge(&mut self, other: Self) {
self.has_failed |= other.has_failed;
self.managed_revision_failed |= other.managed_revision_failed;
}
}
struct ReconcileRound {
req: HeartbeatRequest,
machine_id: uuid::Uuid,
user_id: i32,
running_inst_ids: HashSet<String>,
local_configs: Vec<crate::db::entity::user_running_network_configs::Model>,
target_config_revision: Option<String>,
should_apply_runtime_revision: bool,
}
async fn prepare_reconcile_round(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
req: HeartbeatRequest,
) -> RoundStatus<ReconcileRound> {
let Some(machine_id) = req.machine_id.map(uuid::Uuid::from) else {
tracing::warn!(?req, "Machine id is not set, ignore");
return RoundStatus::Skip;
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &req).await {
tracing::debug!(?machine_id, "skip stale heartbeat reconcile request");
return RoundStatus::Skip;
}
let user_id = match storage
.db
.get_user_id_by_token(req.user_token.clone())
.await
{
Ok(Some(user_id)) => user_id,
Ok(None) => {
tracing::info!("User not found by token: {:?}", req.user_token);
return RoundStatus::Stop;
}
Err(e) => {
tracing::error!("Failed to get user id by token, error: {:?}", e);
return RoundStatus::Stop;
}
};
let applied_config_revision = {
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
data.read().await.applied_config_revision.clone()
};
let target_config_revision = match storage
.db
.get_managed_config_revision((user_id, machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to read managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
let should_apply_runtime_revision =
target_config_revision.is_some() && target_config_revision != applied_config_revision;
let running_inst_ids = match running_instance_ids_for_round(
rpc_client,
&req,
user_id,
machine_id,
should_apply_runtime_revision,
)
.await
{
RoundStatus::Ready(ids) => ids,
RoundStatus::Skip => return RoundStatus::Skip,
RoundStatus::Stop => return RoundStatus::Stop,
};
let local_configs = match storage
.db
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!("Failed to list network configs, error: {:?}", e);
return RoundStatus::Stop;
}
};
RoundStatus::Ready(ReconcileRound {
req,
machine_id,
user_id,
running_inst_ids,
local_configs,
target_config_revision,
should_apply_runtime_revision,
})
}
async fn running_instance_ids_for_round(
rpc_client: &mut SessionRpcClient,
req: &HeartbeatRequest,
user_id: i32,
machine_id: uuid::Uuid,
should_apply_runtime_revision: bool,
) -> RoundStatus<HashSet<String>> {
if !should_apply_runtime_revision {
return RoundStatus::Ready(
req.running_network_instances
.iter()
.map(|x| x.to_string())
.collect(),
);
}
match rpc_client
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
.await
{
Ok(resp) => RoundStatus::Ready(resp.inst_ids.iter().map(|x| x.to_string()).collect()),
Err(error) => {
tracing::warn!(
?user_id,
?machine_id,
?error,
"Failed to refresh running instances for managed config revision"
);
RoundStatus::Skip
}
}
}
async fn sync_running_sources_for_round(
rpc_client: &mut SessionRpcClient,
storage: &StorageInner,
round: &mut ReconcileRound,
) -> RoundStatus<Option<Vec<NetworkMeta>>> {
if !round.req.support_config_source {
return RoundStatus::Ready(None);
}
let ret = if round.running_inst_ids.is_empty() {
Ok(Vec::new())
} else {
rpc_client
.list_network_instance_meta(
BaseController::default(),
ListNetworkInstanceMetaRequest {
inst_ids: managed_config::parse_instance_ids(
round.running_inst_ids.iter().cloned(),
),
},
)
.await
.map(|resp| resp.metas)
};
match ret {
Ok(metas) => {
if let Err(e) = managed_config::sync_running_config_sources(
&storage.db,
round.user_id,
round.machine_id,
&round.local_configs,
&metas,
)
.await
{
tracing::warn!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
%e,
"Failed to sync running network config sources"
);
} else if !metas.is_empty() {
round.local_configs = match storage
.db
.list_network_configs(
(round.user_id, round.machine_id),
ListNetworkProps::EnabledOnly,
)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!(
"Failed to reload network configs after source sync, error: {:?}",
e
);
return RoundStatus::Stop;
}
};
}
RoundStatus::Ready(Some(metas))
}
Err(e) => {
tracing::warn!(
user_id = ?round.user_id,
%e,
"Failed to list running network instance metadata"
);
RoundStatus::Ready(None)
}
}
}
async fn cleanup_stale_web_source_instances(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
running_metas: Option<&[NetworkMeta]>,
desired_web_inst_ids: &HashSet<String>,
cache: &mut ReconcileCache,
) -> RoundStatus<ReconcileOutcome> {
let desired_changed = cache
.last_desired_web_inst_ids
.as_ref()
.is_none_or(|last| last != desired_web_inst_ids);
if cache.cleaned_web_source_instances && !desired_changed {
return RoundStatus::Ready(ReconcileOutcome::default());
}
let db_web_inst_ids = match storage
.db
.list_network_configs((round.user_id, round.machine_id), ListNetworkProps::All)
.await
{
Ok(configs) => managed_config::desired_web_source_instance_ids(&configs),
Err(e) => {
tracing::error!("Failed to list all network configs, error: {:?}", e);
return RoundStatus::Stop;
}
};
let running_web_inst_ids = managed_config::running_web_source_instance_ids(
&round.running_inst_ids,
&db_web_inst_ids,
running_metas,
);
let should_delete_inst_ids = running_web_inst_ids
.difference(desired_web_inst_ids)
.cloned()
.collect::<HashSet<_>>();
let should_delete_ids =
managed_config::parse_instance_ids(should_delete_inst_ids.iter().cloned());
let mut outcome = ReconcileOutcome::default();
if !should_delete_ids.is_empty() {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
"skip stale cleanup because webhook session is no longer current"
);
return RoundStatus::Skip;
}
let ret = rpc_client
.delete_network_instance(
BaseController::default(),
DeleteNetworkInstanceRequest {
inst_ids: should_delete_ids,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Clean stale web-source network instances on heartbeat: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_err() {
outcome.record_failure(true);
} else {
cache.runtime_configs.forget_many(&should_delete_inst_ids);
}
}
if !outcome.has_failed {
cache.cleaned_web_source_instances = true;
cache.last_desired_web_inst_ids = Some(desired_web_inst_ids.clone());
}
RoundStatus::Ready(outcome)
}
async fn reconcile_desired_runtime_configs(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
round: &ReconcileRound,
cache: &mut ReconcileCache,
) -> ReconcileOutcome {
let mut outcome = ReconcileOutcome::default();
// After stale web-owned instances are removed, start every enabled
// config that the latest heartbeat did not report as running. When
// a managed config revision is pending, also reconcile running
// web-owned configs before reporting that revision as applied.
for config in &round.local_configs {
let source = PersistedConfigSource::from_db(&config.source);
let is_running = round.running_inst_ids.contains(&config.network_instance_id);
let should_reconcile_running_web_config = is_running
&& round.should_apply_runtime_revision
&& source == PersistedConfigSource::Web;
if is_running && !should_reconcile_running_web_config {
continue;
}
let desired_config = match serde_json::from_str::<NetworkConfig>(&config.network_config) {
Ok(cfg) => cfg,
Err(e) => {
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to deserialize network config, skipping: {:?}",
e
);
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
continue;
}
};
let action_result = if should_reconcile_running_web_config {
reconcile_running_web_config(
session_data,
rpc_client,
config_client,
round,
config,
desired_config,
&mut cache.runtime_configs,
)
.await
} else {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
let action_result = run_missing_network_config(
session_data,
rpc_client,
round,
config,
desired_config.clone(),
)
.await;
if matches!(action_result, ConfigActionResult::Success)
&& source == PersistedConfigSource::Web
{
if let Err(e) = remember_web_runtime_config_after_run(
rpc_client,
&config.network_instance_id,
&desired_config,
&mut cache.runtime_configs,
)
.await
{
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to cache runtime config after run: {:?}",
e
);
ConfigActionResult::Failed
} else {
action_result
}
} else {
action_result
}
};
match action_result {
ConfigActionResult::Success => {}
ConfigActionResult::Failed => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web)
}
ConfigActionResult::StopRound => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
break;
}
}
}
outcome
}
async fn reconcile_running_web_config(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
round: &ReconcileRound,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> ConfigActionResult {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip runtime reconcile because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = async {
let action =
match runtime_config_cache.plan(&config.network_instance_id, desired_config.clone())? {
Some(action) => action,
None => {
runtime_reconcile::prepare_web_source_runtime_reconcile(
&mut *rpc_client,
&config.network_instance_id,
desired_config.clone(),
true,
)
.await?
}
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
anyhow::bail!("webhook session is no longer current before runtime reconcile apply");
}
let observed_config = runtime_reconcile::apply_web_source_runtime_reconcile(
&mut *rpc_client,
&mut *config_client,
&config.network_instance_id,
desired_config.clone(),
action,
)
.await?;
runtime_config_cache.remember(&config.network_instance_id, observed_config);
Ok::<(), anyhow::Error>(())
}
.await;
tracing::info!(
user_id = ?round.user_id,
instance_id = %config.network_instance_id,
"Reconcile running web-source network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
runtime_config_cache.forget(&config.network_instance_id);
ConfigActionResult::Failed
}
}
async fn run_missing_network_config(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
) -> ConfigActionResult {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip run network instance because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = rpc_client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(config.network_instance_id.clone().into()),
config: Some(desired_config),
overwrite: false,
source: PersistedConfigSource::from_db(&config.source).auto_run_rpc_source() as i32,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Run network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
ConfigActionResult::Failed
}
}
async fn remember_web_runtime_config_after_run(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
desired_config: &NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let observed_config = runtime_reconcile::get_runtime_config(rpc_client, inst_id).await?;
remember_if_runtime_matches_desired(
inst_id,
desired_config,
observed_config,
runtime_config_cache,
)
}
fn remember_if_runtime_matches_desired(
inst_id: &str,
desired_config: &NetworkConfig,
observed_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let action = runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
&observed_config,
desired_config.clone(),
)?;
if !matches!(action, runtime_reconcile::RuntimeReconcileAction::None) {
anyhow::bail!("runtime config still differs after managed run");
}
runtime_config_cache.remember(inst_id, observed_config);
Ok(())
}
async fn mark_config_revision_applied_if_current(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
round: &ReconcileRound,
outcome: &ReconcileOutcome,
) -> RoundStatus<()> {
if outcome.managed_revision_failed || !round.should_apply_runtime_revision {
return RoundStatus::Ready(());
}
let current_target_config_revision = match storage
.db
.get_managed_config_revision((round.user_id, round.machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to verify managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
if current_target_config_revision != round.target_config_revision {
return RoundStatus::Ready(());
}
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
return RoundStatus::Ready(());
}
data.applied_config_revision = round.target_config_revision.clone();
RoundStatus::Ready(())
}
#[cfg(test)]
mod tests {
use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig};
use super::*;
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
NetworkConfig {
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
dhcp: Some(true),
network_name: Some("managed".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
port_forwards,
..Default::default()
}
}
fn port_forward(bind_port: u32, dst_port: u32) -> PortForwardConfig {
PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port,
dst_ip: "10.144.0.1".to_string(),
dst_port,
proto: "tcp".to_string(),
}
}
#[test]
fn session_runtime_config_cache_misses_unknown_instance() {
let cache = SessionRuntimeConfigCache::default();
let action = cache
.plan("missing", config_with_port_forwards(Vec::new()))
.expect("prepare action");
assert!(action.is_none());
}
#[test]
fn session_runtime_config_cache_skips_matching_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
cache.remember("managed", config.clone());
let action = cache
.plan("managed", config)
.expect("prepare action")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn session_runtime_config_cache_plans_patch_from_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
cache.remember("managed", current);
let action = cache
.plan("managed", desired)
.expect("prepare action")
.expect("cached action");
let runtime_reconcile::RuntimeReconcileAction::Patch(patch) = action else {
panic!("expected cached runtime config to produce hot patch");
};
assert_eq!(patch.port_forwards.len(), 1);
}
#[test]
fn session_runtime_config_cache_retain_desired_removes_stale_entries() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("keep", config.clone());
cache.remember("drop", config);
cache.retain_desired(&HashSet::from(["keep".to_string()]));
assert!(cache.entries.contains_key("keep"));
assert!(!cache.entries.contains_key("drop"));
}
#[test]
fn session_runtime_config_cache_forget_removes_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("managed", config.clone());
cache.forget("managed");
let action = cache
.plan("managed", config)
.expect("prepare action after remove");
assert!(action.is_none());
}
#[test]
fn missing_run_remembers_observed_config_when_it_matches_desired() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
remember_if_runtime_matches_desired("managed", &config, config.clone(), &mut cache)
.expect("remember observed config after run");
let action = cache
.plan("managed", config)
.expect("prepare action after run")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn missing_run_does_not_remember_observed_config_that_still_differs() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let err = remember_if_runtime_matches_desired("managed", &desired, current, &mut cache)
.expect_err("expected stale run result not to be cached");
assert!(
err.to_string()
.contains("runtime config still differs after managed run")
);
let action = cache
.plan("managed", desired)
.expect("prepare action after stale run result");
assert!(action.is_none());
}
}
@@ -0,0 +1,402 @@
use std::{sync::Arc, time::Duration};
use anyhow::Context as _;
use easytier::proto::web::HeartbeatRequest;
use tokio::sync::RwLock;
use super::{
SessionAuthState, SessionData, SessionRpcService, WebhookConnectNotification,
WebhookDisconnectNotification, send_webhook_connection_transition,
};
use crate::{
client_manager::storage::{Storage, StorageToken},
webhook::SharedWebhookConfig,
};
pub(super) const VALIDATION_RETRY_MS: u64 = 60_000;
pub(super) struct WebhookHeartbeatValidation {
pub(super) config_revision: String,
pub(super) binding_version: u64,
}
pub(super) struct WebhookValidationInput {
pub(super) storage: Storage,
pub(super) webhook_config: SharedWebhookConfig,
pub(super) client_url: url::Url,
pub(super) applied_config_revision: Option<String>,
pub(super) req: HeartbeatRequest,
pub(super) machine_id: uuid::Uuid,
}
fn deterministic_machine_delay(machine_id: uuid::Uuid, max_delay_ms: u64) -> Duration {
let delay_ms = (machine_id.as_u128() % u128::from(max_delay_ms + 1)) as u64;
Duration::from_millis(delay_ms)
}
pub(super) fn retry_delay(machine_id: uuid::Uuid) -> Duration {
Duration::from_millis(VALIDATION_RETRY_MS)
+ deterministic_machine_delay(machine_id, VALIDATION_RETRY_MS)
}
async fn request_heartbeat_validation(
webhook_config: &crate::webhook::WebhookConfig,
client_url: &url::Url,
persisted_config_revision: Option<&str>,
applied_config_revision: Option<&str>,
req: &HeartbeatRequest,
machine_id: uuid::Uuid,
) -> anyhow::Result<Option<WebhookHeartbeatValidation>> {
let webhook_req = crate::webhook::ValidateTokenRequest {
token: req.user_token.clone(),
machine_id: machine_id.to_string(),
public_ip: client_url.host_str().map(str::to_string),
hostname: req.hostname.clone(),
version: req.easytier_version.clone(),
os_type: req.device_os.as_ref().map(|info| info.os_type.clone()),
os_version: req.device_os.as_ref().map(|info| info.version.clone()),
os_distribution: req.device_os.as_ref().map(|info| info.distribution.clone()),
web_instance_id: webhook_config.web_instance_id.clone(),
web_instance_api_base_url: webhook_config.web_instance_api_base_url.clone(),
persisted_config_revision: persisted_config_revision.map(str::to_string),
applied_config_revision: applied_config_revision.map(str::to_string),
};
let resp = webhook_config
.validate_token(&webhook_req)
.await
.map_err(|e| anyhow::anyhow!("Webhook token validation failed: {:?}", e))?;
if !resp.valid {
return Ok(None);
}
Ok(Some(WebhookHeartbeatValidation {
config_revision: resp.config_revision,
binding_version: resp.binding_version,
}))
}
async fn resolve_user_id(storage: &Storage, token: &str) -> anyhow::Result<i32> {
let user_id = match storage
.db()
.get_user_id_by_token(token)
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
{
Some(id) => id,
None => storage
.auto_create_user(token)
.await
.with_context(|| format!("Failed to auto-create webhook user: {:?}", token))?,
};
Ok(user_id)
}
async fn persisted_config_revision_for_token(
storage: &Storage,
token: &str,
machine_id: uuid::Uuid,
) -> anyhow::Result<Option<String>> {
let Some(user_id) = storage
.db()
.get_user_id_by_token(token)
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
else {
return Ok(None);
};
storage
.db()
.get_managed_config_revision((user_id, machine_id))
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))
}
async fn wait_for_input(
session_data: std::sync::Weak<RwLock<SessionData>>,
) -> Option<WebhookValidationInput> {
loop {
let notify = {
let session_data = session_data.upgrade()?;
let mut data = session_data.write().await;
if matches!(data.auth_state, SessionAuthState::Invalid) {
data.webhook_validation_dirty = false;
tracing::info!(
client_url = %data.client_url,
"webhook validation stopped for invalid session; reconnect is required before revalidation"
);
return None;
}
if data.webhook_validation_dirty {
data.webhook_validation_dirty = false;
let req = data.req.clone()?;
let machine_id = req.machine_id.map(Into::into)?;
let storage = Storage::try_from(data.storage.clone()).ok()?;
return Some(WebhookValidationInput {
storage,
webhook_config: data.webhook_config.clone(),
client_url: data.client_url.clone(),
applied_config_revision: data.applied_config_revision.clone(),
req,
machine_id,
});
}
data.webhook_validation_notify.clone()
};
notify.notified().await;
}
}
pub(super) async fn run_worker(session_data: std::sync::Weak<RwLock<SessionData>>) {
while let Some(input) = wait_for_input(session_data.clone()).await {
let machine_id = input.machine_id;
if let Err(error) = run_round(session_data.clone(), input).await {
tracing::warn!(
?machine_id,
%error,
"webhook validation failed, will retry later"
);
tokio::time::sleep(retry_delay(machine_id)).await;
mark_dirty_if_current(&session_data, machine_id).await;
}
}
}
pub(super) async fn run_round(
session_data: std::sync::Weak<RwLock<SessionData>>,
input: WebhookValidationInput,
) -> anyhow::Result<()> {
let persisted_config_revision = persisted_config_revision_for_token(
&input.storage,
&input.req.user_token,
input.machine_id,
)
.await?;
let validation = request_heartbeat_validation(
&input.webhook_config,
&input.client_url,
persisted_config_revision.as_deref(),
input.applied_config_revision.as_deref(),
&input.req,
input.machine_id,
)
.await?;
let Some(validation) = validation else {
apply_rejected(&session_data, &input).await;
return Ok(());
};
let user_id = resolve_user_id(&input.storage, &input.req.user_token).await?;
apply_success(&session_data, input, validation, user_id).await;
Ok(())
}
async fn mark_dirty_if_current(
session_data: &std::sync::Weak<RwLock<SessionData>>,
machine_id: uuid::Uuid,
) {
let Some(session_data) = session_data.upgrade() else {
return;
};
let notify = {
let mut data = session_data.write().await;
let Some(req) = data.req.as_ref() else {
return;
};
if req.machine_id.map(uuid::Uuid::from) != Some(machine_id) {
return;
}
if matches!(data.auth_state, SessionAuthState::Invalid) {
data.webhook_validation_dirty = false;
tracing::debug!(
%machine_id,
"skip webhook validation retry for invalid session"
);
return;
}
SessionRpcService::mark_webhook_validation_dirty_locked(&mut data)
};
notify.notify_one();
}
pub(super) async fn apply_rejected(
session_data: &std::sync::Weak<RwLock<SessionData>>,
input: &WebhookValidationInput,
) {
let Some(session_data) = session_data.upgrade() else {
return;
};
let (storage_token, disconnect_notification) = {
let mut data = session_data.write().await;
if !data.req.as_ref().is_some_and(|req| {
SessionRpcService::heartbeat_matches_identity(
req,
&input.req.user_token,
input.machine_id,
)
}) {
return;
}
tracing::info!(
machine_id = %input.machine_id,
client_url = %data.client_url,
"webhook token rejected; marking session invalid and requiring client reconnect"
);
data.auth_state = SessionAuthState::Invalid;
data.webhook_validation_dirty = false;
data.binding_version = None;
data.applied_config_revision = None;
let storage_token = data.storage_token.clone();
let disconnect_notification = storage_token.as_ref().and_then(|storage_token| {
data.webhook_connected_binding_version
.take()
.map(|binding_version| WebhookDisconnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version,
})
});
(storage_token, disconnect_notification)
};
if let Some(storage_token) = storage_token {
let report_time = SessionRpcService::heartbeat_report_timestamp(&input.req);
input
.storage
.update_client(storage_token, report_time, false);
}
if disconnect_notification.is_some() {
wait_webhook_connection_transition(
Arc::downgrade(&session_data),
disconnect_notification,
None,
)
.await;
}
}
pub(super) async fn apply_success(
session_data: &std::sync::Weak<RwLock<SessionData>>,
input: WebhookValidationInput,
validation: WebhookHeartbeatValidation,
user_id: i32,
) {
let WebhookHeartbeatValidation {
config_revision: _,
binding_version,
} = validation;
let Some(session_data) = session_data.upgrade() else {
return;
};
let (storage_token, notifier, disconnect_notification, connect_notification, runtime_req) = {
let mut data = session_data.write().await;
let Some(runtime_req) = data.req.clone() else {
return;
};
if !SessionRpcService::heartbeat_matches_identity(
&runtime_req,
&input.req.user_token,
input.machine_id,
) {
return;
}
if matches!(data.auth_state, SessionAuthState::Invalid) {
tracing::info!(
machine_id = %input.machine_id,
client_url = %data.client_url,
"ignore webhook validation success for invalid session; reconnect is required before revalidation"
);
return;
}
let previous_connected_binding_version = data.webhook_connected_binding_version;
let client_url = data.client_url.clone();
let storage_token = data.storage_token.get_or_insert_with(|| StorageToken {
token: runtime_req.user_token.clone(),
client_url,
machine_id: input.machine_id,
user_id,
});
let storage_token = storage_token.clone();
data.auth_state = SessionAuthState::Authorized;
data.binding_version = Some(binding_version);
let should_notify_connected = previous_connected_binding_version != Some(binding_version);
let disconnect_notification = previous_connected_binding_version
.filter(|previous_binding_version| *previous_binding_version != binding_version)
.map(|previous_binding_version| {
data.webhook_connected_binding_version = None;
WebhookDisconnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version: previous_binding_version,
}
});
let connect_notification = should_notify_connected.then(|| WebhookConnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version,
req: crate::webhook::NodeConnectedRequest {
machine_id: input.machine_id.to_string(),
token: runtime_req.user_token.clone(),
user_id: Some(user_id),
hostname: runtime_req.hostname.clone(),
version: runtime_req.easytier_version.clone(),
os_type: runtime_req
.device_os
.as_ref()
.map(|info| info.os_type.clone()),
os_version: runtime_req
.device_os
.as_ref()
.map(|info| info.version.clone()),
os_distribution: runtime_req
.device_os
.as_ref()
.map(|info| info.distribution.clone()),
web_instance_id: data.webhook_config.web_instance_id.clone(),
binding_version: Some(binding_version),
},
});
(
storage_token,
data.notifier.clone(),
disconnect_notification,
connect_notification,
runtime_req,
)
};
if disconnect_notification.is_some() || connect_notification.is_some() {
wait_webhook_connection_transition(
Arc::downgrade(&session_data),
disconnect_notification,
connect_notification,
)
.await;
}
let report_time = SessionRpcService::heartbeat_report_timestamp(&runtime_req);
input
.storage
.update_client(storage_token, report_time, true);
let _ = notifier.send(runtime_req);
}
async fn wait_webhook_connection_transition(
session_data: std::sync::Weak<RwLock<SessionData>>,
disconnect: Option<WebhookDisconnectNotification>,
connect: Option<WebhookConnectNotification>,
) {
let transition = tokio::spawn(send_webhook_connection_transition(
session_data,
disconnect,
connect,
));
if let Err(error) = transition.await {
tracing::warn!(%error, "webhook connection transition task failed");
}
}
+123 -9
View File
@@ -17,6 +17,7 @@ pub struct StorageToken {
struct ClientInfo { struct ClientInfo {
storage_token: StorageToken, storage_token: StorageToken,
report_time: i64, report_time: i64,
authorized: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -55,7 +56,19 @@ impl Storage {
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) { fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
map.entry(client_info.storage_token.machine_id) map.entry(client_info.storage_token.machine_id)
.and_modify(|e| { .and_modify(|e| {
if e.report_time < client_info.report_time { let same_client = e.storage_token.client_url
== client_info.storage_token.client_url
&& e.storage_token.user_id == client_info.storage_token.user_id;
let should_replace = if (same_client && e.authorized != client_info.authorized)
|| (!e.authorized && client_info.authorized)
{
true
} else if e.authorized && !client_info.authorized && !same_client {
false
} else {
e.report_time < client_info.report_time
};
if should_replace {
assert_eq!( assert_eq!(
e.storage_token.machine_id, e.storage_token.machine_id,
client_info.storage_token.machine_id client_info.storage_token.machine_id
@@ -66,12 +79,13 @@ impl Storage {
.or_insert(client_info.clone()); .or_insert(client_info.clone());
} }
pub fn update_client(&self, stoken: StorageToken, report_time: i64) { pub fn update_client(&self, stoken: StorageToken, report_time: i64, authorized: bool) {
let inner = self.0.user_clients_map.entry(stoken.user_id).or_default(); let inner = self.0.user_clients_map.entry(stoken.user_id).or_default();
let client_info = ClientInfo { let client_info = ClientInfo {
storage_token: stoken.clone(), storage_token: stoken.clone(),
report_time, report_time,
authorized,
}; };
Self::update_client_info_map(&inner, &client_info); Self::update_client_info_map(&inner, &client_info);
} }
@@ -93,11 +107,21 @@ impl Storage {
&self, &self,
user_id: UserIdInDb, user_id: UserIdInDb,
machine_id: &uuid::Uuid, machine_id: &uuid::Uuid,
) -> Option<url::Url> {
self.get_client_url_by_machine_id_with_auth(user_id, machine_id, true)
}
pub fn get_client_url_by_machine_id_with_auth(
&self,
user_id: UserIdInDb,
machine_id: &uuid::Uuid,
require_authorized: bool,
) -> Option<url::Url> { ) -> Option<url::Url> {
self.0.user_clients_map.get(&user_id).and_then(|info_map| { self.0.user_clients_map.get(&user_id).and_then(|info_map| {
info_map info_map.get(machine_id).and_then(|info| {
.get(machine_id) (!require_authorized || info.authorized)
.map(|info| info.storage_token.client_url.clone()) .then(|| info.storage_token.client_url.clone())
})
}) })
} }
@@ -108,6 +132,7 @@ impl Storage {
.map(|info_map| { .map(|info_map| {
info_map info_map
.iter() .iter()
.filter(|info| info.value().authorized)
.map(|info| info.value().storage_token.client_url.clone()) .map(|info| info.value().storage_token.client_url.clone())
.collect() .collect()
}) })
@@ -115,6 +140,14 @@ impl Storage {
} }
pub fn list_clients(&self) -> Vec<StorageToken> { pub fn list_clients(&self) -> Vec<StorageToken> {
self.list_clients_with_auth(true)
}
pub fn list_all_clients(&self) -> Vec<StorageToken> {
self.list_clients_with_auth(false)
}
fn list_clients_with_auth(&self, require_authorized: bool) -> Vec<StorageToken> {
self.0 self.0
.user_clients_map .user_clients_map
.iter() .iter()
@@ -122,6 +155,7 @@ impl Storage {
user_clients user_clients
.value() .value()
.iter() .iter()
.filter(|info| !require_authorized || info.value().authorized)
.map(|info| info.value().storage_token.clone()) .map(|info| info.value().storage_token.clone())
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
@@ -164,8 +198,8 @@ mod tests {
let user1_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001"); let user1_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, machine_id, "tcp://127.0.0.1:1002"); let user2_token = make_storage_token(2, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10); storage.update_client(user1_token.clone(), 10, true);
storage.update_client(user2_token.clone(), 20); storage.update_client(user2_token.clone(), 20, true);
assert_eq!( assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id), storage.get_client_url_by_machine_id(1, &machine_id),
@@ -195,8 +229,8 @@ mod tests {
let user1_token = make_storage_token(1, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1001"); let user1_token = make_storage_token(1, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1002"); let user2_token = make_storage_token(2, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10); storage.update_client(user1_token.clone(), 10, true);
storage.update_client(user2_token.clone(), 20); storage.update_client(user2_token.clone(), 20, true);
let tokens = storage.list_clients(); let tokens = storage.list_clients();
assert_eq!(tokens.len(), 2); assert_eq!(tokens.len(), 2);
@@ -209,4 +243,84 @@ mod tests {
assert_eq!(tokens.len(), 1); assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].token, user2_token.token); assert_eq!(tokens[0].token, user2_token.token);
} }
#[tokio::test]
async fn pending_client_is_listed_but_not_authorized_for_machine_lookup() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
storage.update_client(token.clone(), 10, false);
assert_eq!(storage.list_clients().len(), 0);
assert_eq!(storage.list_all_clients().len(), 1);
assert_eq!(storage.list_user_clients(1), Vec::<url::Url>::new());
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
assert_eq!(
storage.get_client_url_by_machine_id_with_auth(1, &machine_id, false),
Some(token.client_url.clone())
);
storage.update_client(token.clone(), 11, true);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(token.client_url.clone())
);
storage.update_client(token.clone(), 11, false);
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
assert_eq!(storage.list_clients().len(), 0);
assert_eq!(storage.list_all_clients().len(), 1);
}
#[tokio::test]
async fn stale_client_authorization_update_does_not_replace_newer_client() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let old_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let new_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(old_token.clone(), 10, true);
storage.update_client(new_token.clone(), 20, true);
storage.update_client(old_token, 10, false);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(new_token.client_url)
);
}
#[tokio::test]
async fn pending_client_does_not_replace_authorized_route() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let authorized_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let pending_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(authorized_token.clone(), 10, true);
storage.update_client(pending_token, i64::MAX, false);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(authorized_token.client_url)
);
}
#[tokio::test]
async fn authorized_client_replaces_pending_route_regardless_of_report_time() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let pending_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let authorized_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(pending_token, i64::MAX, false);
storage.update_client(authorized_token.clone(), 10, true);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(authorized_token.client_url)
);
}
} }
@@ -0,0 +1,38 @@
//! `SeaORM` Entity, hand-written to match the generated entity style.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "managed_config_revisions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
#[sea_orm(column_type = "Text")]
pub device_id: String,
#[sea_orm(column_type = "Text")]
pub config_revision: String,
pub create_time: DateTimeWithTimeZone,
pub update_time: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+1
View File
@@ -4,6 +4,7 @@ pub mod prelude;
pub mod groups; pub mod groups;
pub mod groups_permissions; pub mod groups_permissions;
pub mod managed_config_revisions;
pub mod permissions; pub mod permissions;
pub mod tower_sessions; pub mod tower_sessions;
pub mod user_running_network_configs; pub mod user_running_network_configs;
+1
View File
@@ -2,6 +2,7 @@
pub use super::groups::Entity as Groups; pub use super::groups::Entity as Groups;
pub use super::groups_permissions::Entity as GroupsPermissions; pub use super::groups_permissions::Entity as GroupsPermissions;
pub use super::managed_config_revisions::Entity as ManagedConfigRevisions;
pub use super::permissions::Entity as Permissions; pub use super::permissions::Entity as Permissions;
pub use super::tower_sessions::Entity as TowerSessions; pub use super::tower_sessions::Entity as TowerSessions;
pub use super::user_running_network_configs::Entity as UserRunningNetworkConfigs; pub use super::user_running_network_configs::Entity as UserRunningNetworkConfigs;
+146
View File
@@ -141,6 +141,110 @@ impl Db {
) -> Result<Option<UserIdInDb>, DbErr> { ) -> Result<Option<UserIdInDb>, DbErr> {
self.get_user_id(token).await self.get_user_id(token).await
} }
pub async fn get_managed_config_revision(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
) -> Result<Option<String>, DbErr> {
use entity::managed_config_revisions as mcr;
let revision = mcr::Entity::find()
.filter(mcr::Column::UserId.eq(user_id))
.filter(mcr::Column::DeviceId.eq(device_id.to_string()))
.one(self.orm_db())
.await?;
Ok(revision.map(|row| row.config_revision))
}
pub async fn set_managed_config_revision(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
config_revision: &str,
) -> Result<(), DbErr> {
use entity::managed_config_revisions as mcr;
let now = chrono::Local::now().fixed_offset();
let on_conflict = OnConflict::columns([mcr::Column::UserId, mcr::Column::DeviceId])
.update_columns([mcr::Column::ConfigRevision, mcr::Column::UpdateTime])
.to_owned();
let insert_m = mcr::ActiveModel {
user_id: Set(user_id),
device_id: Set(device_id.to_string()),
config_revision: Set(config_revision.to_string()),
create_time: Set(now),
update_time: Set(now),
..Default::default()
};
mcr::Entity::insert(insert_m)
.on_conflict(on_conflict)
.do_nothing()
.exec(self.orm_db())
.await?;
Ok(())
}
pub async fn insert_or_update_web_network_config(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_id: Uuid,
network_config: NetworkConfig,
) -> Result<bool, DbErr> {
let now = chrono::Local::now().fixed_offset();
let network_config =
serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?;
let source = ConfigSource::Web.as_str();
let result = sqlx::query(
r#"
INSERT INTO user_running_network_configs (
user_id, device_id, network_instance_id, network_config,
source, disabled, create_time, update_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, device_id, network_instance_id) DO UPDATE SET
network_config = excluded.network_config,
source = excluded.source,
disabled = excluded.disabled,
update_time = excluded.update_time
WHERE user_running_network_configs.source = ?
"#,
)
.bind(user_id)
.bind(device_id.to_string())
.bind(network_inst_id.to_string())
.bind(network_config)
.bind(source)
.bind(false)
.bind(now)
.bind(now)
.bind(source)
.execute(&self.db)
.await
.map_err(|e| DbErr::Custom(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
pub async fn delete_web_network_configs(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_ids: &[Uuid],
) -> Result<(), DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::delete_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
.filter(urnc::Column::Source.eq(ConfigSource::Web.as_str()))
.filter(
urnc::Column::NetworkInstanceId
.is_in(network_inst_ids.iter().map(|id| id.to_string())),
)
.exec(self.orm_db())
.await?;
Ok(())
}
} }
#[async_trait] #[async_trait]
@@ -468,4 +572,46 @@ mod tests {
assert_eq!(device1_configs.len(), 1); assert_eq!(device1_configs.len(), 1);
assert_eq!(device2_configs.len(), 1); assert_eq!(device2_configs.len(), 1);
} }
#[tokio::test]
async fn test_web_network_config_does_not_replace_user_owned_config() {
let db = Db::memory_db().await;
let user_id = db.auto_create_user("user-web-race").await.unwrap().id;
let device_id = uuid::Uuid::new_v4();
let inst_id = uuid::Uuid::new_v4();
db.insert_or_update_user_network_config(
(user_id, device_id),
inst_id,
NetworkConfig {
network_name: Some("user-owned".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
let updated = db
.insert_or_update_web_network_config(
(user_id, device_id),
inst_id,
NetworkConfig {
network_name: Some("web-owned".to_string()),
..Default::default()
},
)
.await
.unwrap();
assert!(!updated);
let saved = db
.get_network_config((user_id, device_id), &inst_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(saved.get_network_config_source(), ConfigSource::User);
let saved_config = saved.get_network_config().unwrap();
assert_eq!(saved_config.network_name.as_deref(), Some("user-owned"));
}
} }
+10 -1
View File
@@ -3,8 +3,8 @@
#[macro_use] #[macro_use]
extern crate rust_i18n; extern crate rust_i18n;
use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use std::{net::IpAddr, time::Duration};
use clap::Parser; use clap::Parser;
use easytier::tunnel::websocket::WsTunnelListener; use easytier::tunnel::websocket::WsTunnelListener;
@@ -113,6 +113,14 @@ struct Cli {
)] )]
geoip_db: Option<String>, geoip_db: Option<String>,
#[arg(
long,
env = "ET_HEARTBEAT_MIN_RESPONSE_MS",
default_value = "0",
help = t!("cli.heartbeat_min_response_ms").to_string(),
)]
heartbeat_min_response_ms: u64,
#[cfg(feature = "embed")] #[cfg(feature = "embed")]
#[arg( #[arg(
long, long,
@@ -312,6 +320,7 @@ async fn main() {
let mut mgr = client_manager::ClientManager::new( let mut mgr = client_manager::ClientManager::new(
db.clone(), db.clone(),
cli.geoip_db, cli.geoip_db,
Duration::from_millis(cli.heartbeat_min_response_ms),
feature_flags.clone(), feature_flags.clone(),
webhook_config.clone(), webhook_config.clone(),
); );
@@ -0,0 +1,46 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260619_000005_managed_config_revisions"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
CREATE TABLE managed_config_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
config_revision TEXT NOT NULL,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL,
CONSTRAINT fk_managed_config_revisions_user_id_to_users_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
CREATE UNIQUE INDEX idx_managed_config_revisions_scope
ON managed_config_revisions(user_id, device_id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared("DROP TABLE managed_config_revisions;")
.await?;
Ok(())
}
}
+2
View File
@@ -4,6 +4,7 @@ mod m20241029_000001_init;
mod m20260403_000002_scope_network_config_unique; mod m20260403_000002_scope_network_config_unique;
mod m20260421_000003_add_network_config_source; mod m20260421_000003_add_network_config_source;
mod m20260514_000004_rename_web_config_source; mod m20260514_000004_rename_web_config_source;
mod m20260619_000005_managed_config_revisions;
pub struct Migrator; pub struct Migrator;
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260403_000002_scope_network_config_unique::Migration), Box::new(m20260403_000002_scope_network_config_unique::Migration),
Box::new(m20260421_000003_add_network_config_source::Migration), Box::new(m20260421_000003_add_network_config_source::Migration),
Box::new(m20260514_000004_rename_web_config_source::Migration), Box::new(m20260514_000004_rename_web_config_source::Migration),
Box::new(m20260619_000005_managed_config_revisions::Migration),
] ]
} }
} }
+1 -1
View File
@@ -307,7 +307,7 @@ impl RestfulServer {
async fn handle_list_all_sessions_internal( async fn handle_list_all_sessions_internal(
State(client_mgr): AppState, State(client_mgr): AppState,
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> { ) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
let ret = client_mgr.list_sessions().await; let ret = client_mgr.list_all_sessions().await;
Ok(ListSessionJsonResp(ret).into()) Ok(ListSessionJsonResp(ret).into())
} }
+15 -5
View File
@@ -93,6 +93,8 @@ struct ManagedNetworkConfigJson {
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ReconcileManagedNetworkConfigsJsonReq { struct ReconcileManagedNetworkConfigsJsonReq {
managed_network_configs: Vec<ManagedNetworkConfigJson>, managed_network_configs: Vec<ManagedNetworkConfigJson>,
config_revision: Option<String>,
expected_config_revision: Option<String>,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -357,13 +359,21 @@ impl NetworkApi {
}) })
.collect(); .collect();
client_mgr client_mgr
.reconcile_managed_network_configs(user_id, machine_id, desired) .reconcile_managed_network_configs(
user_id,
machine_id,
desired,
payload.config_revision,
payload.expected_config_revision,
)
.await .await
.map_err(|err| { .map_err(|err| {
( let status = if crate::client_manager::is_managed_config_revision_conflict(&err) {
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::CONFLICT
other_error(err.to_string()).into(), } else {
) StatusCode::INTERNAL_SERVER_ERROR
};
(status, other_error(err.to_string()).into())
})?; })?;
Ok(Void::default().into()) Ok(Void::default().into())
} }
+589 -16
View File
@@ -1,6 +1,248 @@
use std::sync::Arc; use std::{
cmp::Ordering,
collections::VecDeque,
fmt,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
const VALIDATE_TOKEN_INITIAL_CONCURRENCY: usize = 8;
const VALIDATE_TOKEN_MIN_CONCURRENCY: usize = 2;
const VALIDATE_TOKEN_MAX_CONCURRENCY: usize = 64;
const VALIDATE_TOKEN_ADJUST_WINDOW: Duration = Duration::from_secs(1);
const VALIDATE_TOKEN_SLOW_THRESHOLD: Duration = Duration::from_secs(2);
const WEBHOOK_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
struct AdaptiveValidateLimiter {
state: Mutex<AdaptiveValidateLimiterState>,
}
impl fmt::Debug for AdaptiveValidateLimiter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdaptiveValidateLimiter")
.field("state", &self.lock_state())
.finish_non_exhaustive()
}
}
struct AdaptiveValidateLimiterState {
limit: usize,
in_flight: usize,
waiters: VecDeque<oneshot::Sender<AdaptiveValidateGrant>>,
window_started_at: Instant,
samples: usize,
slow_samples: usize,
failures: usize,
had_queue: bool,
}
impl fmt::Debug for AdaptiveValidateLimiterState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdaptiveValidateLimiterState")
.field("limit", &self.limit)
.field("in_flight", &self.in_flight)
.field("waiters", &self.waiters.len())
.field("window_started_at", &self.window_started_at)
.field("samples", &self.samples)
.field("slow_samples", &self.slow_samples)
.field("failures", &self.failures)
.field("had_queue", &self.had_queue)
.finish()
}
}
struct AdaptiveValidatePermit {
limiter: Arc<AdaptiveValidateLimiter>,
started_at: Instant,
completed: bool,
}
struct AdaptiveValidateGrant {
limiter: Arc<AdaptiveValidateLimiter>,
active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LimitAdjustment {
Unchanged,
Increased,
Decreased,
}
impl AdaptiveValidateLimiter {
fn new() -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(AdaptiveValidateLimiterState::new(Instant::now())),
})
}
async fn acquire(self: &Arc<Self>) -> AdaptiveValidatePermit {
loop {
let receiver = {
let mut state = self.lock_state();
state.complete_window_if_due(Instant::now());
if state.waiters.is_empty() && state.in_flight < state.limit {
state.in_flight += 1;
return AdaptiveValidatePermit::new(self.clone());
}
let (sender, receiver) = oneshot::channel();
state.had_queue = true;
state.waiters.push_back(sender);
self.grant_waiters(&mut state);
receiver
};
if let Ok(grant) = receiver.await {
return grant.into_permit();
}
}
}
fn grant_waiters(self: &Arc<Self>, state: &mut AdaptiveValidateLimiterState) {
while state.in_flight < state.limit {
let Some(waiter) = state.waiters.pop_front() else {
break;
};
state.in_flight += 1;
if let Err(mut grant) = waiter.send(AdaptiveValidateGrant::new(self.clone())) {
grant.disarm();
state.in_flight -= 1;
}
}
}
fn record_sample(self: &Arc<Self>, elapsed: Duration, success: bool) {
let mut state = self.lock_state();
let adjustment = state.record_sample(Instant::now(), elapsed, success);
if adjustment == LimitAdjustment::Increased {
self.grant_waiters(&mut state);
}
}
fn release_slot(self: &Arc<Self>) {
let mut state = self.lock_state();
state.in_flight = state.in_flight.saturating_sub(1);
self.grant_waiters(&mut state);
}
fn lock_state(&self) -> std::sync::MutexGuard<'_, AdaptiveValidateLimiterState> {
self.state
.lock()
.expect("adaptive validate limiter state should not be poisoned")
}
}
impl AdaptiveValidateLimiterState {
fn new(now: Instant) -> Self {
Self {
limit: VALIDATE_TOKEN_INITIAL_CONCURRENCY,
in_flight: 0,
waiters: VecDeque::new(),
window_started_at: now,
samples: 0,
slow_samples: 0,
failures: 0,
had_queue: false,
}
}
fn record_sample(&mut self, now: Instant, elapsed: Duration, success: bool) -> LimitAdjustment {
self.samples += 1;
if elapsed > VALIDATE_TOKEN_SLOW_THRESHOLD {
self.slow_samples += 1;
}
if !success {
self.failures += 1;
}
self.complete_window_if_due(now)
}
fn complete_window_if_due(&mut self, now: Instant) -> LimitAdjustment {
if now.duration_since(self.window_started_at) < VALIDATE_TOKEN_ADJUST_WINDOW {
return LimitAdjustment::Unchanged;
}
let old_limit = self.limit;
if self.samples > 0 {
if self.failures > 0 || self.is_p95_slow() {
self.limit = (self.limit / 2).max(VALIDATE_TOKEN_MIN_CONCURRENCY);
} else if self.had_queue {
self.limit = (self.limit + 1).min(VALIDATE_TOKEN_MAX_CONCURRENCY);
}
}
self.window_started_at = now;
self.samples = 0;
self.slow_samples = 0;
self.failures = 0;
self.had_queue = false;
match self.limit.cmp(&old_limit) {
Ordering::Greater => LimitAdjustment::Increased,
Ordering::Less => LimitAdjustment::Decreased,
Ordering::Equal => LimitAdjustment::Unchanged,
}
}
fn is_p95_slow(&self) -> bool {
self.slow_samples > 0 && self.slow_samples * 20 >= self.samples
}
}
impl AdaptiveValidatePermit {
fn new(limiter: Arc<AdaptiveValidateLimiter>) -> Self {
Self {
limiter,
started_at: Instant::now(),
completed: false,
}
}
fn complete(mut self, success: bool) {
self.limiter
.record_sample(self.started_at.elapsed(), success);
self.completed = true;
}
}
impl AdaptiveValidateGrant {
fn new(limiter: Arc<AdaptiveValidateLimiter>) -> Self {
Self {
limiter,
active: true,
}
}
fn into_permit(mut self) -> AdaptiveValidatePermit {
self.active = false;
AdaptiveValidatePermit::new(self.limiter.clone())
}
fn disarm(&mut self) {
self.active = false;
}
}
impl Drop for AdaptiveValidateGrant {
fn drop(&mut self) {
if self.active {
self.limiter.release_slot();
}
}
}
impl Drop for AdaptiveValidatePermit {
fn drop(&mut self) {
if !self.completed {
self.limiter.record_sample(self.started_at.elapsed(), false);
}
self.limiter.release_slot();
}
}
/// Webhook configuration for external integrations. /// Webhook configuration for external integrations.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -11,6 +253,7 @@ pub struct WebhookConfig {
pub web_instance_id: Option<String>, pub web_instance_id: Option<String>,
pub web_instance_api_base_url: Option<String>, pub web_instance_api_base_url: Option<String>,
validate_limiter: Arc<AdaptiveValidateLimiter>,
client: reqwest::Client, client: reqwest::Client,
} }
@@ -28,7 +271,11 @@ impl WebhookConfig {
internal_auth_token, internal_auth_token,
web_instance_id, web_instance_id,
web_instance_api_base_url, web_instance_api_base_url,
client: reqwest::Client::new(), validate_limiter: AdaptiveValidateLimiter::new(),
client: reqwest::Client::builder()
.timeout(WEBHOOK_HTTP_TIMEOUT)
.build()
.expect("webhook HTTP client should be valid"),
} }
} }
@@ -58,6 +305,8 @@ pub struct ValidateTokenRequest {
pub web_instance_id: Option<String>, pub web_instance_id: Option<String>,
pub web_instance_api_base_url: Option<String>, pub web_instance_api_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub persisted_config_revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_config_revision: Option<String>, pub applied_config_revision: Option<String>,
} }
@@ -69,7 +318,6 @@ pub struct ValidateTokenResponse {
#[serde(default)] #[serde(default)]
pub binding_version: u64, pub binding_version: u64,
#[serde(default)] #[serde(default)]
pub managed_network_configs: Option<Vec<ManagedNetworkConfig>>,
pub config_revision: String, pub config_revision: String,
} }
@@ -125,21 +373,40 @@ impl WebhookConfig {
pub async fn validate_token( pub async fn validate_token(
&self, &self,
req: &ValidateTokenRequest, req: &ValidateTokenRequest,
) -> anyhow::Result<ValidateTokenResponse> {
self.validate_token_with_http_timeout(req, WEBHOOK_HTTP_TIMEOUT)
.await
}
async fn validate_token_with_http_timeout(
&self,
req: &ValidateTokenRequest,
http_timeout: Duration,
) -> anyhow::Result<ValidateTokenResponse> { ) -> anyhow::Result<ValidateTokenResponse> {
let url = self.webhook_endpoint("validate-token")?; let url = self.webhook_endpoint("validate-token")?;
let resp = self let permit = self.validate_limiter.acquire().await;
.client let ret = match tokio::time::timeout(http_timeout, async {
.post(&url) let resp = self
.header("X-Internal-Auth", self.webhook_auth_secret()) .client
.json(req) .post(&url)
.send() .header("X-Internal-Auth", self.webhook_auth_secret())
.await?; .json(req)
.send()
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
anyhow::bail!("webhook validate-token returned status {}", resp.status()); anyhow::bail!("webhook validate-token returned status {}", resp.status());
} }
Ok(resp.json().await?) Ok(resp.json().await?)
})
.await
{
Ok(ret) => ret,
Err(_) => Err(anyhow::anyhow!("webhook validate-token timed out")),
};
permit.complete(ret.is_ok());
ret
} }
/// Notify the webhook receiver that a node has connected. /// Notify the webhook receiver that a node has connected.
@@ -191,13 +458,319 @@ pub type SharedWebhookConfig = Arc<WebhookConfig>;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::{Json, Router, routing::post};
use serde_json::json;
#[test] #[test]
fn validate_token_response_allows_missing_managed_configs() { fn adaptive_validate_limiter_increases_under_queue_pressure() {
let now = Instant::now();
let mut state = AdaptiveValidateLimiterState::new(now);
state.had_queue = true;
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
state.record_sample(now, Duration::from_millis(50), true);
}
assert_eq!(
state.complete_window_if_due(now + VALIDATE_TOKEN_ADJUST_WINDOW),
LimitAdjustment::Increased
);
assert_eq!(state.limit, VALIDATE_TOKEN_INITIAL_CONCURRENCY + 1);
}
#[test]
fn adaptive_validate_limiter_does_not_increase_without_queue_pressure() {
let now = Instant::now();
let mut state = AdaptiveValidateLimiterState::new(now);
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
state.record_sample(now, Duration::from_millis(50), true);
}
assert_eq!(
state.complete_window_if_due(now + VALIDATE_TOKEN_ADJUST_WINDOW),
LimitAdjustment::Unchanged
);
assert_eq!(state.limit, VALIDATE_TOKEN_INITIAL_CONCURRENCY);
}
#[test]
fn adaptive_validate_limiter_reduces_on_failure() {
let now = Instant::now();
let mut state = AdaptiveValidateLimiterState::new(now);
state.record_sample(now, Duration::from_millis(50), false);
assert_eq!(
state.complete_window_if_due(now + VALIDATE_TOKEN_ADJUST_WINDOW),
LimitAdjustment::Decreased
);
assert_eq!(
state.limit,
(VALIDATE_TOKEN_INITIAL_CONCURRENCY / 2).max(VALIDATE_TOKEN_MIN_CONCURRENCY)
);
}
#[test]
fn adaptive_validate_limiter_reduces_on_slow_latency() {
let now = Instant::now();
let mut state = AdaptiveValidateLimiterState::new(now);
state.record_sample(
now,
VALIDATE_TOKEN_SLOW_THRESHOLD + Duration::from_millis(1),
true,
);
assert_eq!(
state.complete_window_if_due(now + VALIDATE_TOKEN_ADJUST_WINDOW),
LimitAdjustment::Decreased
);
assert_eq!(
state.limit,
(VALIDATE_TOKEN_INITIAL_CONCURRENCY / 2).max(VALIDATE_TOKEN_MIN_CONCURRENCY)
);
}
#[tokio::test]
async fn adaptive_validate_limiter_waiter_acquires_after_release() {
let limiter = AdaptiveValidateLimiter::new();
let mut permits = Vec::new();
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
permits.push(limiter.acquire().await);
}
let waiter_limiter = limiter.clone();
let waiter = tokio::spawn(async move {
let permit = waiter_limiter.acquire().await;
permit.complete(true);
});
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!waiter.is_finished());
permits.pop().unwrap().complete(true);
tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.unwrap()
.unwrap();
for permit in permits {
permit.complete(true);
}
}
#[tokio::test]
async fn adaptive_validate_limiter_releases_when_permit_is_dropped() {
let limiter = AdaptiveValidateLimiter::new();
let mut permits = Vec::new();
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
permits.push(limiter.acquire().await);
}
let waiter_limiter = limiter.clone();
let waiter = tokio::spawn(async move {
let permit = waiter_limiter.acquire().await;
permit.complete(true);
});
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!waiter.is_finished());
drop(permits.pop().unwrap());
tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.unwrap()
.unwrap();
for permit in permits {
permit.complete(true);
}
}
#[tokio::test]
async fn adaptive_validate_limiter_skips_canceled_waiters() {
let limiter = AdaptiveValidateLimiter::new();
let mut permits = Vec::new();
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
permits.push(limiter.acquire().await);
}
let waiter_limiter = limiter.clone();
let waiter = tokio::spawn(async move {
let permit = waiter_limiter.acquire().await;
permit.complete(true);
});
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!waiter.is_finished());
waiter.abort();
assert!(waiter.await.unwrap_err().is_cancelled());
permits.pop().unwrap().complete(true);
tokio::time::sleep(Duration::from_millis(10)).await;
let state = limiter.lock_state();
assert_eq!(state.samples, 1);
assert_eq!(state.failures, 0);
drop(state);
for permit in permits {
permit.complete(true);
}
}
#[test]
fn adaptive_validate_limiter_releases_dropped_grant_without_failure_sample() {
let limiter = AdaptiveValidateLimiter::new();
{
let mut state = limiter.lock_state();
state.in_flight = 1;
}
drop(AdaptiveValidateGrant::new(limiter.clone()));
let state = limiter.lock_state();
assert_eq!(state.in_flight, 0);
assert_eq!(state.samples, 0);
assert_eq!(state.failures, 0);
}
#[tokio::test]
async fn adaptive_validate_limiter_wakes_multiple_waiters_in_order() {
let limiter = AdaptiveValidateLimiter::new();
let mut permits = Vec::new();
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
permits.push(limiter.acquire().await);
}
let (first_acquired_tx, first_acquired_rx) = oneshot::channel();
let (first_release_tx, first_release_rx) = oneshot::channel();
let first = {
let limiter = limiter.clone();
tokio::spawn(async move {
let permit = limiter.acquire().await;
first_acquired_tx.send(()).unwrap();
first_release_rx.await.unwrap();
permit.complete(true);
})
};
let (second_acquired_tx, mut second_acquired_rx) = oneshot::channel();
let second = {
let limiter = limiter.clone();
tokio::spawn(async move {
let permit = limiter.acquire().await;
second_acquired_tx.send(()).unwrap();
permit.complete(true);
})
};
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!first.is_finished());
assert!(!second.is_finished());
permits.pop().unwrap().complete(true);
tokio::time::timeout(Duration::from_secs(1), first_acquired_rx)
.await
.unwrap()
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut second_acquired_rx)
.await
.is_err()
);
first_release_tx.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(1), first)
.await
.unwrap()
.unwrap();
tokio::time::timeout(Duration::from_secs(1), &mut second_acquired_rx)
.await
.unwrap()
.unwrap();
tokio::time::timeout(Duration::from_secs(1), second)
.await
.unwrap()
.unwrap();
for permit in permits {
permit.complete(true);
}
}
#[tokio::test]
async fn validate_token_http_timeout_starts_after_limiter_permit() {
let app = Router::new().route(
"/validate-token",
post(|| async {
Json(json!({
"valid": true,
"config_revision": "rev-1"
}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let webhook = WebhookConfig::new(Some(format!("http://{addr}")), None, None, None, None);
let mut permits = Vec::new();
for _ in 0..VALIDATE_TOKEN_INITIAL_CONCURRENCY {
permits.push(webhook.validate_limiter.acquire().await);
}
let validate_webhook = webhook.clone();
let validate = tokio::spawn(async move {
let req = ValidateTokenRequest {
token: "token".to_string(),
machine_id: uuid::Uuid::new_v4().to_string(),
public_ip: None,
hostname: String::new(),
version: String::new(),
os_type: None,
os_version: None,
os_distribution: None,
web_instance_id: None,
web_instance_api_base_url: None,
persisted_config_revision: None,
applied_config_revision: None,
};
validate_webhook
.validate_token_with_http_timeout(&req, Duration::from_millis(20))
.await
});
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!validate.is_finished());
permits.pop().unwrap().complete(true);
let resp = tokio::time::timeout(Duration::from_secs(1), validate)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(resp.valid);
for permit in permits {
permit.complete(true);
}
server.abort();
}
#[test]
fn validate_token_response_deserializes_config_revision() {
let resp: ValidateTokenResponse = let resp: ValidateTokenResponse =
serde_json::from_str(r#"{"valid":true,"config_revision":"rev-1"}"#).unwrap(); serde_json::from_str(r#"{"valid":true,"config_revision":"rev-1"}"#).unwrap();
assert!(resp.valid); assert!(resp.valid);
assert_eq!(resp.config_revision, "rev-1"); assert_eq!(resp.config_revision, "rev-1");
assert!(resp.managed_network_configs.is_none()); }
#[test]
fn validate_token_response_allows_missing_config_revision() {
let resp: ValidateTokenResponse = serde_json::from_str(r#"{"valid":true}"#).unwrap();
assert!(resp.valid);
assert!(resp.config_revision.is_empty());
} }
} }
+3 -13
View File
@@ -37,6 +37,7 @@ tracing-subscriber = { version = "0.3", features = [
"local-time", "local-time",
"time", "time",
] } ] }
lttng-ust = { git = "https://github.com/EasyTier/lttng-ust-rs.git", optional = true }
derivative = "2.2.0" derivative = "2.2.0"
derive_more = { version = "2.1.1", features = ["full"] } derive_more = { version = "2.1.1", features = ["full"] }
console-subscriber = { version = "0.4.1", optional = true } console-subscriber = { version = "0.4.1", optional = true }
@@ -52,7 +53,6 @@ toml = "0.8.12"
chrono = { version = "0.4.37", features = ["serde"] } chrono = { version = "0.4.37", features = ["serde"] }
guarden = "0.2" guarden = "0.2"
hotpath = { version = "0.18", default-features = false, optional = true }
quanta = "0.12" quanta = "0.12"
delegate = "0.13.5" delegate = "0.13.5"
@@ -85,7 +85,6 @@ atomic_refcell = "0.1.13"
quinn = { version = "0.11.8", optional = true, features = ["ring"] } quinn = { version = "0.11.8", optional = true, features = ["ring"] }
quinn-proto = { version = "0.11.12", optional = true } quinn-proto = { version = "0.11.12", optional = true }
quinn-udp = { version = "0.5", optional = true }
seahash = { version = "4.1.0", optional = true } seahash = { version = "4.1.0", optional = true }
rustls = { version = "0.23.0", features = [ rustls = { version = "0.23.0", features = [
@@ -331,6 +330,7 @@ quote = "1"
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [ thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7", "win7",
] } ] }
lttng-ust-generate = { git = "https://github.com/EasyTier/lttng-ust-rs.git", optional = true }
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
reqwest = { version = "0.12.12", features = ["blocking"] } reqwest = { version = "0.12.12", features = ["blocking"] }
@@ -362,7 +362,6 @@ default = [
"faketcp", "faketcp",
"magic-dns", "magic-dns",
"zstd", "zstd",
"udp-gso",
] ]
full = [ full = [
"websocket", "websocket",
@@ -380,7 +379,6 @@ full = [
] ]
wireguard = ["dep:boringtun", "dep:ring"] wireguard = ["dep:boringtun", "dep:ring"]
quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"] quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"]
udp-gso = ["dep:quinn-udp"]
kcp = ["dep:kcp-sys"] kcp = ["dep:kcp-sys"]
mimalloc = ["dep:mimalloc"] mimalloc = ["dep:mimalloc"]
aes-gcm = ["dep:aes-gcm"] aes-gcm = ["dep:aes-gcm"]
@@ -406,15 +404,7 @@ jemalloc-prof = [
"jemalloc-sys/stats", "jemalloc-sys/stats",
] ]
tracing = ["tokio/tracing", "dep:console-subscriber"] tracing = ["tokio/tracing", "dep:console-subscriber"]
hotpath = [ lttng = ["dep:lttng-ust", "dep:lttng-ust-generate"]
"dep:hotpath",
"hotpath/hotpath",
"hotpath/tokio",
"hotpath/parking_lot",
"hotpath/flume",
]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
magic-dns = ["dep:hickory-client", "dep:hickory-server"] magic-dns = ["dep:hickory-client", "dep:hickory-server"]
faketcp = ["dep:flume"] faketcp = ["dep:flume"]
zstd = ["dep:zstd"] zstd = ["dep:zstd"]
+32
View File
@@ -133,6 +133,35 @@ fn check_locale() {
} }
} }
#[cfg(feature = "lttng")]
fn generate_lttng_tracepoints(out: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
use lttng_ust_generate::{CIntegerType, CTFType, Generator, LogLevel, Provider};
let mut provider = Provider::new("easytier");
provider
.create_class("marker")
.add_field("name", CTFType::SequenceText)
.add_field("value", CTFType::Integer(CIntegerType::U64))
.instantiate_with_level("mark", LogLevel::Info);
provider
.create_class("duration")
.add_field("name", CTFType::SequenceText)
.add_field("duration_micros", CTFType::Integer(CIntegerType::U64))
.instantiate_with_level("duration_micros", LogLevel::Info);
Generator::default()
.generated_lib_name("easytier_lttng_tracepoints")
.register_provider(provider)
.output_file_name(out.join("lttng_tracepoints.rs"))
.generate()
.map_err(|error| {
std::io::Error::other(format!("failed to generate LTTng tracepoints: {error:?}"))
})?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
cfg_aliases! { cfg_aliases! {
mobile: { mobile: {
@@ -174,6 +203,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
let out = PathBuf::from(env::var("OUT_DIR")?); let out = PathBuf::from(env::var("OUT_DIR")?);
#[cfg(feature = "lttng")]
generate_lttng_tracepoints(&out)?;
let descriptor = out.join("descriptors.bin"); let descriptor = out.join("descriptors.bin");
let mut config = prost_build::Config::new(); let mut config = prost_build::Config::new();
-232
View File
@@ -1,232 +0,0 @@
//! CPU hotspot benchmark for hotpath profiling.
//!
//! Builds two no-tun EasyTier instances connected via an in-process ring
//! tunnel, lets routes converge, then floods data-plane packets through
//! `send_msg_by_ip` so that `hotpath-cpu` / samply can collect meaningful
//! CPU samples.
//!
//! Build & run:
//! cargo run --profile hotpath --features hotpath,hotpath-cpu \
//! --example cpu_hotspot_ring
//!
//! Prerequisites: hotpath-samply + samply must be installed and on PATH.
//! See bench/006-hotpath-cpu-top.md for install instructions.
//!
//! Then in another terminal:
//! hotpath console
#[cfg(feature = "mimalloc")]
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[cfg(feature = "jemalloc")]
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use bytes::BytesMut;
use easytier::common::config::{ConfigLoader, PeerConfig, TomlConfigLoader};
use easytier::instance::instance::Instance;
use easytier::tunnel::packet_def::ZCPacket;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::tunnel::udp::UdpTunnelConnector;
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
#[cfg_attr(feature = "hotpath", hotpath::main)]
async fn main() {
let duration = std::env::var("HOTPATH_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30u64);
let pkt_size: usize = std::env::var("HOTPATH_PKT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1400);
let tunnel_type = std::env::var("HOTPATH_TUNNEL")
.ok()
.unwrap_or_else(|| "ring".to_string());
let (inst_a_config, inst_b_config) = match tunnel_type.as_str() {
"udp" => {
let mut a = no_tun_config("hot-a", "10.144.144.1");
a.set_listeners(vec!["udp://0.0.0.0:35521".parse().unwrap()]);
(a, no_tun_config("hot-b", "10.144.144.2"))
}
"tcp" => {
let mut a = no_tun_config("hot-a", "10.144.144.1");
a.set_listeners(vec!["tcp://0.0.0.0:35522".parse().unwrap()]);
(a, no_tun_config("hot-b", "10.144.144.2"))
}
_ => (
no_tun_config("hot-a", "10.144.144.1"),
no_tun_config("hot-b", "10.144.144.2"),
),
};
let mut inst_a = Instance::new(inst_a_config);
let mut inst_b = Instance::new(inst_b_config);
inst_a.run().await.expect("inst_a run");
inst_b.run().await.expect("inst_b run");
tokio::time::sleep(Duration::from_secs(1)).await;
match tunnel_type.as_str() {
"ring" => {
let ring_url = format!("ring://{}", inst_a.id());
inst_b
.get_conn_manager()
.add_connector(RingTunnelConnector::new(ring_url.parse().unwrap()));
}
"udp" => {
inst_b.get_conn_manager().add_connector(
UdpTunnelConnector::new("udp://127.0.0.1:35521".parse().unwrap()),
);
}
"tcp" => {
inst_b.get_conn_manager().add_connector(
easytier::tunnel::tcp::TcpTunnelConnector::new(
"tcp://127.0.0.1:35522".parse().unwrap(),
),
);
}
_ => {}
}
let dst: IpAddr = "10.144.144.2".parse().unwrap();
let src = "10.144.144.1";
let converged = tokio::time::timeout(Duration::from_secs(15), async {
loop {
let a = inst_a.get_peer_manager().list_routes().await;
let b = inst_b.get_peer_manager().list_routes().await;
if a.len() >= 1 && b.len() >= 1 {
return true;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.is_ok();
if !converged {
eprintln!("warning: routes did not converge within 15s");
}
println!(
"cpu_hotspot_ring: flooding {}s, pkt_size={}, tunnel={} (converged={})",
duration, pkt_size, tunnel_type, converged
);
let pm = inst_a.get_peer_manager();
let send_pkt = make_data_packet(src, "10.144.144.2", pkt_size);
let batch_threshold: u32 = std::env::var("HOTPATH_BATCH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
// After convergence, enable batch flush for writev optimization
if converged && batch_threshold > 1 {
pm.set_peer_conn_batch_threshold(batch_threshold);
println!("cpu_hotspot_ring: batch_threshold={}", batch_threshold);
}
let pipeline_depth: usize = std::env::var("HOTPATH_PIPELINE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
println!(
"cpu_hotspot_ring: pipeline_depth={}",
pipeline_depth
);
let sender_task = tokio::spawn(async move {
use futures::stream::{FuturesUnordered, StreamExt};
let mut sent: u64 = 0;
let start = Instant::now();
let mut in_flight = FuturesUnordered::new();
loop {
while in_flight.len() < pipeline_depth {
let pkt = send_pkt.clone();
in_flight.push(pm.send_msg_by_ip(pkt, dst, false));
}
in_flight.next().await;
sent += 1;
if sent % 10000 == 0 {
let elapsed = start.elapsed().as_secs_f64();
let pps = sent as f64 / elapsed;
let mbps = pps * pkt_size as f64 * 8.0 / 1_000_000.0;
println!("sent {} pkts ({:.0} pps, {:.0} Mbps)", sent, pps, mbps);
}
}
});
tokio::time::sleep(Duration::from_secs(duration)).await;
sender_task.abort();
println!("cpu_hotspot_ring: done");
}
fn make_data_packet(src: &str, dst: &str, total_size: usize) -> ZCPacket {
use std::net::Ipv4Addr;
let hdr_len = 28;
let payload_len = total_size.saturating_sub(hdr_len);
let ip_total_len = (hdr_len + payload_len) as u16;
let mut buf = BytesMut::with_capacity(total_size);
buf.extend_from_slice(&[
0x45,
0x00,
(ip_total_len >> 8) as u8,
(ip_total_len & 0xff) as u8,
0x00,
0x00,
0x40,
0x00,
0x40,
0x11,
0x00,
0x00,
]);
let src: Ipv4Addr = src.parse().unwrap();
buf.extend_from_slice(&src.octets());
let dst: Ipv4Addr = dst.parse().unwrap();
buf.extend_from_slice(&dst.octets());
let udp_len = (8 + payload_len) as u16;
buf.extend_from_slice(&[
0x30,
0x39,
0xD4,
0x31,
(udp_len >> 8) as u8,
(udp_len & 0xff) as u8,
0x00,
0x00,
]);
buf.resize(total_size, 0xAA);
ZCPacket::new_with_payload(&buf)
}
fn no_tun_config(name: &str, ipv4: &str) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(name.to_owned());
config.set_ipv4(Some(ipv4.parse().unwrap()));
let mut flags = config.get_flags();
flags.no_tun = true;
config.set_flags(flags);
config
}
+1 -1
View File
@@ -6,7 +6,7 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use hotpath::instant::Instant; use quanta::Instant;
use crate::common::{config::ConfigLoader, global_ctx::ArcGlobalCtx, token_bucket::TokenBucket}; use crate::common::{config::ConfigLoader, global_ctx::ArcGlobalCtx, token_bucket::TokenBucket};
use crate::proto::acl::*; use crate::proto::acl::*;
-1
View File
@@ -129,7 +129,6 @@ impl Compressor for DefaultCompressor {
Ok(()) Ok(())
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "DefaultCompressor"))]
async fn decompress(&self, zc_packet: &mut ZCPacket) -> Result<(), Error> { async fn decompress(&self, zc_packet: &mut ZCPacket) -> Result<(), Error> {
let pm_header = zc_packet.peer_manager_header().unwrap(); let pm_header = zc_packet.peer_manager_header().unwrap();
if !pm_header.is_compressed() { if !pm_header.is_compressed() {
+23
View File
@@ -0,0 +1,23 @@
#[cfg(feature = "lttng")]
lttng_ust::import_tracepoints!(
concat!(env!("OUT_DIR"), "/lttng_tracepoints.rs"),
tracepoints
);
#[cfg(feature = "lttng")]
pub fn mark(name: &str, value: u64) {
tracepoints::easytier::mark(name, value);
}
#[cfg(not(feature = "lttng"))]
#[inline(always)]
pub fn mark(_name: &str, _value: u64) {}
#[cfg(feature = "lttng")]
pub fn duration_micros(name: &str, duration_micros: u64) {
tracepoints::easytier::duration_micros(name, duration_micros);
}
#[cfg(not(feature = "lttng"))]
#[inline(always)]
pub fn duration_micros(_name: &str, _duration_micros: u64) {}
+1
View File
@@ -18,6 +18,7 @@ pub mod global_ctx;
pub mod idn; pub mod idn;
pub mod ifcfg; pub mod ifcfg;
pub mod log; pub mod log;
pub mod lttng;
pub mod machine_id; pub mod machine_id;
pub mod netns; pub mod netns;
pub mod network; pub mod network;
+1 -1
View File
@@ -1,5 +1,5 @@
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant; use quanta::Instant;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cell::UnsafeCell; use std::cell::UnsafeCell;
use std::fmt; use std::fmt;
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::proto::common::{NatType, StunInfo};
use anyhow::Context; use anyhow::Context;
use chrono::Local; use chrono::Local;
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use hotpath::instant::Instant; use quanta::Instant;
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
use socket2::{SockAddr, SockRef}; use socket2::{SockAddr, SockRef};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
+114 -21
View File
@@ -11,7 +11,7 @@ use std::{
time::Duration, time::Duration,
}; };
use hotpath::instant::Instant; use quanta::Instant;
use crate::{ use crate::{
common::{ common::{
@@ -50,6 +50,7 @@ use url::Host;
pub const DIRECT_CONNECTOR_SERVICE_ID: u32 = 1; pub const DIRECT_CONNECTOR_SERVICE_ID: u32 = 1;
pub const DIRECT_CONNECTOR_BLACKLIST_TIMEOUT_SEC: u64 = 300; pub const DIRECT_CONNECTOR_BLACKLIST_TIMEOUT_SEC: u64 = 300;
const MAX_IPV6_HOLE_PUNCH_CONNECTOR_ADDRS: usize = 16;
static TESTING: AtomicBool = AtomicBool::new(false); static TESTING: AtomicBool = AtomicBool::new(false);
@@ -84,6 +85,56 @@ fn is_usable_public_ipv6_candidate_with_mode(
&& !ip.is_multicast())) && !ip.is_multicast()))
} }
fn push_ipv6_hole_punch_candidate(
candidates: &mut Vec<Ipv6Addr>,
ip: Ipv6Addr,
global_ctx: &ArcGlobalCtx,
limit: usize,
) {
if candidates.len() >= limit
|| !is_usable_public_ipv6_candidate(&ip, global_ctx)
|| candidates.contains(&ip)
{
return;
}
candidates.push(ip);
}
async fn collect_ipv6_hole_punch_candidates(global_ctx: &ArcGlobalCtx) -> Vec<Ipv6Addr> {
let mut candidates = Vec::new();
for ip in global_ctx
.get_stun_info_collector()
.get_stun_info()
.public_ip
.iter()
.filter_map(|ip| ip.parse::<Ipv6Addr>().ok())
{
push_ipv6_hole_punch_candidate(
&mut candidates,
ip,
global_ctx,
MAX_IPV6_HOLE_PUNCH_CONNECTOR_ADDRS,
);
}
let ip_list = global_ctx.get_ip_collector().collect_ip_addrs().await;
for ip in ip_list
.interface_ipv6s
.iter()
.chain(ip_list.public_ipv6.iter())
.map(|ip| Ipv6Addr::from(*ip))
{
push_ipv6_hole_punch_candidate(
&mut candidates,
ip,
global_ctx,
MAX_IPV6_HOLE_PUNCH_CONNECTOR_ADDRS,
);
}
candidates
}
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait PeerManagerForDirectConnector { pub trait PeerManagerForDirectConnector {
async fn list_peers(&self) -> Vec<PeerId>; async fn list_peers(&self) -> Vec<PeerId>;
@@ -153,7 +204,8 @@ impl DirectConnectorManagerData {
async fn remote_send_udp_hole_punch_packet( async fn remote_send_udp_hole_punch_packet(
&self, &self,
dst_peer_id: PeerId, dst_peer_id: PeerId,
connector_addr: SocketAddr, connector_addrs: Vec<SocketAddr>,
preferred_src_ipv6: Option<Ipv6Addr>,
remote_url: &url::Url, remote_url: &url::Url,
) -> Result<(), Error> { ) -> Result<(), Error> {
if !matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) { if !matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) {
@@ -184,15 +236,17 @@ impl DirectConnectorManagerData {
.send_udp_hole_punch_packet( .send_udp_hole_punch_packet(
BaseController::default(), BaseController::default(),
SendUdpHolePunchPacketRequest { SendUdpHolePunchPacketRequest {
connector_addr: connector_addrs.first().copied().map(Into::into),
listener_port: listener_port as u32, listener_port: listener_port as u32,
connector_addr: Some(connector_addr.into()), preferred_src_ipv6: preferred_src_ipv6.map(Into::into),
connector_addrs: connector_addrs.into_iter().map(Into::into).collect(),
}, },
) )
.await .await
.with_context(|| { .with_context(|| {
format!( format!(
"do rpc, send udp hole punch packet to peer {} at {}", "do rpc, send udp hole punch packet to peer {} at {} with preferred source {:?}",
dst_peer_id, remote_url dst_peer_id, remote_url, preferred_src_ipv6
) )
})?; })?;
@@ -209,23 +263,41 @@ impl DirectConnectorManagerData {
.await .await
.with_context(|| format!("failed to bind local socket for {}", remote_url))?, .with_context(|| format!("failed to bind local socket for {}", remote_url))?,
); );
let connector_ip = self let connector_ips = collect_ipv6_hole_punch_candidates(&self.global_ctx).await;
.global_ctx
.get_stun_info_collector()
.get_stun_info()
.public_ip
.iter()
.filter_map(|ip| ip.parse::<Ipv6Addr>().ok())
.find(|ip| !self.global_ctx.is_ip_easytier_managed_ipv6(ip));
// ask remote to send v6 hole punch packet // ask remote to send v6 hole punch packet
// and no matter what the result is, continue to connect // and no matter what the result is, continue to connect
if let Some(connector_ip) = connector_ip { if !connector_ips.is_empty() {
let connector_addr = let local_port = local_socket.local_addr()?.port();
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port()); let connector_addrs = connector_ips
let _ = self .into_iter()
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url) .map(|ip| SocketAddr::new(IpAddr::V6(ip), local_port))
.await; .collect::<Vec<_>>();
let preferred_src_ipv6 = match remote_url.host() {
Some(Host::Ipv6(ip)) => Some(ip),
_ => None,
};
tracing::debug!(
?connector_addrs,
?preferred_src_ipv6,
?remote_url,
"request remote IPv6 hole-punch packets"
);
if let Err(err) = self
.remote_send_udp_hole_punch_packet(
dst_peer_id,
connector_addrs,
preferred_src_ipv6,
remote_url,
)
.await
{
tracing::debug!(
?err,
?remote_url,
"remote IPv6 hole-punch packet request failed"
);
}
} else { } else {
tracing::debug!( tracing::debug!(
?remote_url, ?remote_url,
@@ -267,7 +339,7 @@ impl DirectConnectorManagerData {
.with_context(|| format!("failed to get udp port mapping for {}", remote_url))?; .with_context(|| format!("failed to get udp port mapping for {}", remote_url))?;
let _ = self let _ = self
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url) .remote_send_udp_hole_punch_packet(dst_peer_id, vec![connector_addr], None, remote_url)
.await; .await;
let udp_connector = UdpTunnelConnector::new(remote_url.clone()); let udp_connector = UdpTunnelConnector::new(remote_url.clone());
@@ -818,7 +890,7 @@ mod tests {
tunnel::{IpScheme, TunnelScheme, matches_scheme}, tunnel::{IpScheme, TunnelScheme, matches_scheme},
}; };
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs}; use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs};
@@ -840,6 +912,27 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn ipv6_hole_punch_candidates_are_deduped_filtered_and_capped() {
let global_ctx = get_mock_global_ctx();
let managed_ipv6: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
global_ctx.set_public_ipv6_routes(BTreeSet::from([managed_ipv6]));
let first: Ipv6Addr = "2001:db8::1".parse().unwrap();
let managed = managed_ipv6.address();
let second: Ipv6Addr = "2001:db8::3".parse().unwrap();
let third: Ipv6Addr = "2001:db8::4".parse().unwrap();
let mut candidates = Vec::new();
super::push_ipv6_hole_punch_candidate(&mut candidates, first, &global_ctx, 2);
super::push_ipv6_hole_punch_candidate(&mut candidates, first, &global_ctx, 2);
super::push_ipv6_hole_punch_candidate(&mut candidates, managed, &global_ctx, 2);
super::push_ipv6_hole_punch_candidate(&mut candidates, second, &global_ctx, 2);
super::push_ipv6_hole_punch_candidate(&mut candidates, third, &global_ctx, 2);
assert_eq!(candidates, vec![first, second]);
}
#[test] #[test]
fn udp_ipv6_url_matches_hole_punch_branch_condition() { fn udp_ipv6_url_matches_hole_punch_branch_condition() {
let remote_url: url::Url = "udp://[2001:db8::1]:11010".parse().unwrap(); let remote_url: url::Url = "udp://[2001:db8::1]:11010".parse().unwrap();
+1 -1
View File
@@ -6,7 +6,7 @@ use std::{
}; };
use dashmap::DashSet; use dashmap::DashSet;
use hotpath::instant::Instant; use quanta::Instant;
use tokio::{sync::mpsc, task::JoinSet, time::timeout}; use tokio::{sync::mpsc, task::JoinSet, time::timeout};
use crate::{ use crate::{
-2
View File
@@ -70,8 +70,6 @@ async fn set_bind_addr_for_peer_connector(
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await; let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
if is_ipv4 { if is_ipv4 {
let mut bind_addrs = vec![]; let mut bind_addrs = vec![];
// Always include loopback so localhost connections work
bind_addrs.push(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
for ipv4 in ips.interface_ipv4s { for ipv4 in ips.interface_ipv4s {
let socket_addr = SocketAddrV4::new(ipv4.into(), 0).into(); let socket_addr = SocketAddrV4::new(ipv4.into(), 0).into();
bind_addrs.push(socket_addr); bind_addrs.push(socket_addr);
+1 -1
View File
@@ -5,7 +5,7 @@ use std::{
}; };
use anyhow::{Context, Error}; use anyhow::{Context, Error};
use hotpath::instant::Instant; use quanta::Instant;
use rand::Rng as _; use rand::Rng as _;
use tokio::task::JoinSet; use tokio::task::JoinSet;
@@ -5,7 +5,7 @@ use std::{
}; };
use anyhow::Context; use anyhow::Context;
use hotpath::instant::Instant; use quanta::Instant;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
@@ -7,7 +7,7 @@ use std::{
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use dashmap::{DashMap, DashSet}; use dashmap::{DashMap, DashSet};
use guarden::defer; use guarden::defer;
use hotpath::instant::Instant; use quanta::Instant;
use rand::seq::SliceRandom as _; use rand::seq::SliceRandom as _;
use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet}; use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet};
use tracing::{Instrument, Level, instrument}; use tracing::{Instrument, Level, instrument};
@@ -1,7 +1,7 @@
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use anyhow::Context; use anyhow::Context;
use hotpath::instant::Instant; use quanta::Instant;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
+1 -1
View File
@@ -8,8 +8,8 @@ use both_easy_sym::{PunchBothEasySymHoleClient, PunchBothEasySymHoleServer};
use common::{PunchHoleServerCommon, UdpNatType, UdpPunchClientMethod}; use common::{PunchHoleServerCommon, UdpNatType, UdpPunchClientMethod};
use cone::{PunchConeHoleClient, PunchConeHoleServer}; use cone::{PunchConeHoleClient, PunchConeHoleServer};
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use quanta::Instant;
use sym_to_cone::{PunchSymToConeHoleClient, PunchSymToConeHoleServer}; use sym_to_cone::{PunchSymToConeHoleClient, PunchSymToConeHoleServer};
use tokio::{sync::Mutex, task::JoinHandle}; use tokio::{sync::Mutex, task::JoinHandle};
@@ -10,7 +10,7 @@ use std::{
use anyhow::Context; use anyhow::Context;
use guarden::defer; use guarden::defer;
use hotpath::instant::Instant; use quanta::Instant;
use rand::{Rng, seq::SliceRandom}; use rand::{Rng, seq::SliceRandom};
use tokio::{net::UdpSocket, sync::RwLock}; use tokio::{net::UdpSocket, sync::RwLock};
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
-16
View File
@@ -1,11 +1,5 @@
use easytier::core; use easytier::core;
#[cfg(all(
feature = "hotpath-alloc",
any(feature = "jemalloc", feature = "mimalloc")
))]
compile_error!("feature `hotpath-alloc` cannot be enabled together with `jemalloc` or `mimalloc`");
#[cfg(all(feature = "mimalloc", not(feature = "jemalloc")))] #[cfg(all(feature = "mimalloc", not(feature = "jemalloc")))]
use mimalloc::MiMalloc; use mimalloc::MiMalloc;
@@ -30,16 +24,6 @@ pub static malloc_conf: &[u8] = b"retain:false\0";
rust_i18n::i18n!("locales", fallback = "en"); rust_i18n::i18n!("locales", fallback = "en");
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
#[cfg_attr(
all(
feature = "hotpath",
not(all(
feature = "hotpath-alloc",
any(feature = "jemalloc", feature = "mimalloc")
))
),
hotpath::main
)]
async fn main() -> std::process::ExitCode { async fn main() -> std::process::ExitCode {
core::main().await core::main().await
} }
+1 -1
View File
@@ -7,13 +7,13 @@ use std::{
}; };
use anyhow::Context; use anyhow::Context;
use hotpath::instant::Instant;
use pnet::packet::{ use pnet::packet::{
Packet, Packet,
icmp::{self, IcmpCode, IcmpTypes, MutableIcmpPacket, echo_reply::MutableEchoReplyPacket}, icmp::{self, IcmpCode, IcmpTypes, MutableIcmpPacket, echo_reply::MutableEchoReplyPacket},
ip::IpNextHeaderProtocols, ip::IpNextHeaderProtocols,
ipv4::Ipv4Packet, ipv4::Ipv4Packet,
}; };
use quanta::Instant;
use socket2::Socket; use socket2::Socket;
use tokio::{ use tokio::{
sync::{Mutex, mpsc::UnboundedSender}, sync::{Mutex, mpsc::UnboundedSender},
+1 -1
View File
@@ -1,8 +1,8 @@
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::Packet; use pnet::packet::Packet;
use pnet::packet::ip::IpNextHeaderProtocol; use pnet::packet::ip::IpNextHeaderProtocol;
use pnet::packet::ipv4::{self, Ipv4Flags, Ipv4Packet, MutableIpv4Packet}; use pnet::packet::ipv4::{self, Ipv4Flags, Ipv4Packet, MutableIpv4Packet};
use quanta::Instant;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::time::Duration; use std::time::Duration;
-1
View File
@@ -32,7 +32,6 @@ pub(crate) struct CidrSet {
mapped_to_real: Arc<DashMap<cidr::Ipv4Cidr, cidr::Ipv4Cidr>>, mapped_to_real: Arc<DashMap<cidr::Ipv4Cidr, cidr::Ipv4Cidr>>,
} }
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl CidrSet { impl CidrSet {
pub fn new(global_ctx: ArcGlobalCtx) -> Self { pub fn new(global_ctx: ArcGlobalCtx) -> Self {
let mut ret = Self { let mut ret = Self {
+1 -1
View File
@@ -1018,7 +1018,7 @@ impl TcpProxyRpc for QuicProxyDstRpcService {
mod tests { mod tests {
use super::*; use super::*;
use bytes::Buf; use bytes::Buf;
use hotpath::instant::Instant; use quanta::Instant;
/// Helper function: Create a pair of interconnected QuicSockets. /// Helper function: Create a pair of interconnected QuicSockets.
/// Data sent by socket_a will enter socket_b's rx, and vice versa. /// Data sent by socket_a will enter socket_b's rx, and vice versa.
+702 -59
View File
@@ -9,9 +9,9 @@ use std::{
}; };
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use hotpath::instant::Instant;
#[cfg(feature = "kcp")] #[cfg(feature = "kcp")]
use kcp_sys::{endpoint::KcpEndpoint, stream::KcpStream}; use kcp_sys::{endpoint::KcpEndpoint, stream::KcpStream};
use quanta::Instant;
use tokio_util::sync::{CancellationToken, DropGuard}; use tokio_util::sync::{CancellationToken, DropGuard};
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
@@ -32,7 +32,7 @@ use crate::{
tunnel::packet_def::{PacketType, ZCPacket}, tunnel::packet_def::{PacketType, ZCPacket},
}; };
use anyhow::Context; use anyhow::Context;
use dashmap::DashMap; use dashmap::{DashMap, mapref::entry::Entry};
use pnet::packet::{ use pnet::packet::{
Packet, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket, Packet, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket,
}; };
@@ -164,6 +164,87 @@ struct Socks5Entry {
type Socks5EntrySet = Arc<DashMap<Socks5Entry, Socks5EntryData>>; type Socks5EntrySet = Arc<DashMap<Socks5Entry, Socks5EntryData>>;
fn increment_entry_count(entry_count: &AtomicUsize) -> (usize, usize) {
let old_entry_count = entry_count
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
count.checked_add(1)
})
.unwrap_or_else(|count| count);
(old_entry_count, old_entry_count.saturating_add(1))
}
fn decrement_entry_count(entry_count: &AtomicUsize) -> (usize, usize) {
decrement_entry_count_by(entry_count, 1)
}
fn decrement_entry_count_by(entry_count: &AtomicUsize, delta: usize) -> (usize, usize) {
if delta == 0 {
let current = entry_count.load(Ordering::Relaxed);
return (current, current);
}
let old_entry_count = entry_count
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
Some(count.saturating_sub(delta))
})
.unwrap_or_else(|count| count);
(old_entry_count, old_entry_count.saturating_sub(delta))
}
fn insert_entry_and_increment_count(
entries: &Socks5EntrySet,
entry_count: &AtomicUsize,
entry: Socks5Entry,
data: Socks5EntryData,
) -> (bool, usize, usize) {
match entries.entry(entry) {
Entry::Occupied(mut occupied) => {
occupied.insert(data);
let current = entry_count.load(Ordering::Relaxed);
(true, current, current)
}
Entry::Vacant(vacant) => {
// Keep the count update inside the VacantEntry shard lock so bulk clear
// cannot observe the inserted entry before its count is reserved.
let (old_entry_count, new_entry_count) = increment_entry_count(entry_count);
vacant.insert(data);
(false, old_entry_count, new_entry_count)
}
}
}
fn try_insert_entry_and_increment_count(
entries: &Socks5EntrySet,
entry_count: &AtomicUsize,
entry: Socks5Entry,
data: Socks5EntryData,
) -> bool {
match entries.entry(entry) {
Entry::Occupied(_) => false,
Entry::Vacant(vacant) => {
// See insert_entry_and_increment_count for why the count is reserved first.
increment_entry_count(entry_count);
vacant.insert(data);
true
}
}
}
fn remove_entry_and_decrement_count(
entries: &Socks5EntrySet,
entry_count: &AtomicUsize,
entry: &Socks5Entry,
) -> (bool, usize, usize) {
let removed = entries.remove(entry).is_some();
let (old_entry_count, new_entry_count) = if removed {
decrement_entry_count(entry_count)
} else {
let current = entry_count.load(Ordering::Relaxed);
(current, current)
};
(removed, old_entry_count, new_entry_count)
}
struct SmolTcpConnector { struct SmolTcpConnector {
net: Arc<Net>, net: Arc<Net>,
entries: Socks5EntrySet, entries: Socks5EntrySet,
@@ -190,9 +271,20 @@ impl AsyncTcpConnector for SmolTcpConnector {
entry_type: TCP_ENTRY, entry_type: TCP_ENTRY,
}; };
*self.current_entry.lock().unwrap() = Some(entry.clone()); *self.current_entry.lock().unwrap() = Some(entry.clone());
self.entries let (replaced, old_entry_count, new_entry_count) = insert_entry_and_increment_count(
.insert(entry, Socks5EntryData::Tcp(tmp_listener)); &self.entries,
self.entry_count.fetch_add(1, Ordering::Relaxed); &self.entry_count,
entry.clone(),
Socks5EntryData::Tcp(tmp_listener),
);
tracing::trace!(
?entry,
replaced,
old_entry_count,
new_entry_count,
entries_len = self.entries.len(),
"socks5 inserted smoltcp tcp connector entry"
);
if addr.ip() == local_addr { if addr.ip() == local_addr {
let modified_addr = let modified_addr =
@@ -220,8 +312,16 @@ impl Drop for SmolTcpConnector {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(entry) = self.current_entry.lock().unwrap().take() { if let Some(entry) = self.current_entry.lock().unwrap().take() {
tracing::debug!("drop smoltcp connector entry {:?}", entry); tracing::debug!("drop smoltcp connector entry {:?}", entry);
self.entries.remove(&entry); let (removed, old_entry_count, new_entry_count) =
self.entry_count.fetch_sub(1, Ordering::Relaxed); remove_entry_and_decrement_count(&self.entries, &self.entry_count, &entry);
tracing::trace!(
?entry,
removed,
old_entry_count,
new_entry_count,
entries_len = self.entries.len(),
"socks5 removed smoltcp tcp connector entry"
);
} }
} }
} }
@@ -294,11 +394,26 @@ impl AsyncTcpConnector for Socks5AutoConnector {
addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), addr.port()); addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), addr.port());
} }
if self.smoltcp_net.is_none() let has_smoltcp_net = self.smoltcp_net.is_some();
|| peer_mgr_arc.get_msg_dst_peer(&addr.ip()).await.0.is_empty() let dst_peers = if has_smoltcp_net && !addr.ip().is_loopback() {
Some(peer_mgr_arc.get_msg_dst_peer(&addr.ip()).await.0)
} else {
None
};
if !has_smoltcp_net
|| dst_peers.as_ref().is_some_and(Vec::is_empty)
|| addr.ip().is_loopback() || addr.ip().is_loopback()
{ {
// cannot find dst in virtual network, so try connect to dst directly // cannot find dst in virtual network, so try connect to dst directly
tracing::trace!(
?addr,
src_addr = ?self.src_addr,
has_smoltcp_net,
dst_peer_count = dst_peers.as_ref().map(Vec::len),
is_loopback = addr.ip().is_loopback(),
"socks5 auto connector falling back to kernel tcp connect"
);
return Ok(SocksTcpStream::Tcp( return Ok(SocksTcpStream::Tcp(
tcp_connect_with_timeout(addr, timeout_s).await?, tcp_connect_with_timeout(addr, timeout_s).await?,
)); ));
@@ -310,25 +425,51 @@ impl AsyncTcpConnector for Socks5AutoConnector {
#[cfg(feature = "kcp")] #[cfg(feature = "kcp")]
let connector: Box<dyn AsyncTcpConnector<S = SocksTcpStream> + Send> = let connector: Box<dyn AsyncTcpConnector<S = SocksTcpStream> + Send> =
match (&self.kcp_endpoint, dst_allow_kcp) { match (&self.kcp_endpoint, dst_allow_kcp) {
(Some(kcp_endpoint), true) => Box::new(Socks5KcpConnector { (Some(kcp_endpoint), true) => {
kcp_endpoint: kcp_endpoint.clone(), tracing::trace!(
peer_mgr: self.peer_mgr.clone(), ?addr,
src_addr: self.src_addr, src_addr = ?self.src_addr,
}), dst_peer_count = dst_peers.as_ref().map(Vec::len),
(_, _) => Box::new(SmolTcpConnector { "socks5 auto connector selected kcp"
net: self.smoltcp_net.clone().unwrap(), );
entries: self.entries.clone(), Box::new(Socks5KcpConnector {
entry_count: self.entry_count.clone(), kcp_endpoint: kcp_endpoint.clone(),
current_entry: std::sync::Mutex::new(None), peer_mgr: self.peer_mgr.clone(),
}), src_addr: self.src_addr,
})
}
(_, _) => {
tracing::trace!(
?addr,
src_addr = ?self.src_addr,
dst_peer_count = dst_peers.as_ref().map(Vec::len),
dst_allow_kcp,
has_kcp_endpoint = self.kcp_endpoint.is_some(),
"socks5 auto connector selected smoltcp"
);
Box::new(SmolTcpConnector {
net: self.smoltcp_net.clone().unwrap(),
entries: self.entries.clone(),
entry_count: self.entry_count.clone(),
current_entry: std::sync::Mutex::new(None),
})
}
}; };
#[cfg(not(feature = "kcp"))] #[cfg(not(feature = "kcp"))]
let connector = Box::new(SmolTcpConnector { let connector = {
net: self.smoltcp_net.clone().unwrap(), tracing::trace!(
entries: self.entries.clone(), ?addr,
entry_count: self.entry_count.clone(), src_addr = ?self.src_addr,
current_entry: std::sync::Mutex::new(None), dst_peer_count = dst_peers.as_ref().map(Vec::len),
}); "socks5 auto connector selected smoltcp"
);
Box::new(SmolTcpConnector {
net: self.smoltcp_net.clone().unwrap(),
entries: self.entries.clone(),
entry_count: self.entry_count.clone(),
current_entry: std::sync::Mutex::new(None),
})
};
let ret = connector.tcp_connect(addr, timeout_s).await; let ret = connector.tcp_connect(addr, timeout_s).await;
self.inner_connector.lock().replace(Box::new(connector)); self.inner_connector.lock().replace(Box::new(connector));
@@ -503,25 +644,89 @@ pub struct Socks5Server {
#[async_trait::async_trait] #[async_trait::async_trait]
impl PeerPacketFilter for Socks5Server { impl PeerPacketFilter for Socks5Server {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> { async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
if self.entry_count.load(Ordering::Relaxed) == 0 let entry_count = self.entry_count.load(Ordering::Relaxed);
&& !self.socks5_enabled.load(Ordering::Relaxed) let socks5_enabled = self.socks5_enabled.load(Ordering::Relaxed);
{ if entry_count == 0 && !socks5_enabled && self.entries.is_empty() {
if tracing::enabled!(tracing::Level::TRACE)
&& let Some(hdr) = packet.peer_manager_header()
&& matches!(
hdr.packet_type,
x if x == PacketType::Data as u8
|| x == PacketType::DataWithKcpSrcModified as u8
|| x == PacketType::DataWithQuicSrcModified as u8
)
{
if let Some(ipv4) = Ipv4Packet::new(packet.payload()) {
let (tcp_src_port, tcp_dst_port, tcp_flags) =
if ipv4.get_next_level_protocol() == IpNextHeaderProtocols::Tcp {
TcpPacket::new(ipv4.payload())
.map(|tcp| {
(
Some(tcp.get_source()),
Some(tcp.get_destination()),
Some(tcp.get_flags()),
)
})
.unwrap_or((None, None, None))
} else {
(None, None, None)
};
tracing::trace!(
packet_type = hdr.packet_type,
from_peer_id = hdr.from_peer_id.get(),
to_peer_id = hdr.to_peer_id.get(),
ipv4_src = %ipv4.get_source(),
ipv4_dst = %ipv4.get_destination(),
next_protocol = ?ipv4.get_next_level_protocol(),
?tcp_src_port,
?tcp_dst_port,
?tcp_flags,
entry_count,
socks5_enabled,
"socks5 fast gate passed packet from peer"
);
} else {
tracing::trace!(
packet_type = hdr.packet_type,
from_peer_id = hdr.from_peer_id.get(),
to_peer_id = hdr.to_peer_id.get(),
entry_count,
socks5_enabled,
"socks5 fast gate passed non-ipv4 packet from peer"
);
}
}
return Some(packet);
}
let hdr = packet.peer_manager_header().unwrap();
let is_modified_src_packet = matches!(
hdr.packet_type,
x if x == PacketType::DataWithKcpSrcModified as u8
|| x == PacketType::DataWithQuicSrcModified as u8
);
if hdr.packet_type != PacketType::Data as u8 && !is_modified_src_packet {
return Some(packet);
}
if is_modified_src_packet && hdr.from_peer_id != hdr.to_peer_id {
tracing::trace!(
packet_type = hdr.packet_type,
from_peer_id = hdr.from_peer_id.get(),
to_peer_id = hdr.to_peer_id.get(),
"socks5 passed non-loopback modified-source packet from peer"
);
return Some(packet); return Some(packet);
} }
let hdr = packet.peer_manager_header().unwrap();
if hdr.packet_type != PacketType::Data as u8 {
return Some(packet);
};
let payload_bytes = packet.payload(); let payload_bytes = packet.payload();
let ipv4 = Ipv4Packet::new(payload_bytes).unwrap(); let Some(ipv4) = Ipv4Packet::new(payload_bytes) else {
return Some(packet);
};
if ipv4.get_version() != 4 { if ipv4.get_version() != 4 {
return Some(packet); return Some(packet);
} }
let entry_key = match ipv4.get_next_level_protocol() { let (entry_key, tcp_flags) = match ipv4.get_next_level_protocol() {
IpNextHeaderProtocols::Tcp => { IpNextHeaderProtocols::Tcp => {
let Some(tcp_packet) = TcpPacket::new(ipv4.payload()) else { let Some(tcp_packet) = TcpPacket::new(ipv4.payload()) else {
return Some(packet); return Some(packet);
@@ -546,11 +751,11 @@ impl PeerPacketFilter for Socks5Server {
entry_type: TCP_LISTEN_ENTRY, entry_type: TCP_LISTEN_ENTRY,
} }
}; };
entry (entry, Some(tcp_packet.get_flags()))
} }
IpNextHeaderProtocols::Udp => { IpNextHeaderProtocols::Udp => {
if IpReassembler::is_packet_fragmented(&ipv4) && !self.entries.is_empty() { if IpReassembler::is_packet_fragmented(&ipv4) {
let ipv4_src: IpAddr = ipv4.get_source().into(); let ipv4_src: IpAddr = ipv4.get_source().into();
// only send to smoltcp if the ipv4 src is in the entries // only send to smoltcp if the ipv4 src is in the entries
let is_in_entries = self.entries.iter().any(|x| x.key().dst.ip() == ipv4_src); let is_in_entries = self.entries.iter().any(|x| x.key().dst.ip() == ipv4_src);
@@ -562,7 +767,19 @@ impl PeerPacketFilter for Socks5Server {
if is_in_entries { if is_in_entries {
// if the packet is fragmented, no matther what the payload is, need send it to both smoltcp and kernel tun. because // if the packet is fragmented, no matther what the payload is, need send it to both smoltcp and kernel tun. because
// we cannot determine the udp port of the packet. // we cannot determine the udp port of the packet.
let _ = self.packet_sender.try_send(packet.clone()).ok(); match self.packet_sender.try_send(packet.clone()) {
Ok(()) => tracing::trace!(
?ipv4_src,
entry_count = self.entry_count.load(Ordering::Relaxed),
"socks5 delivered fragmented packet from peer to smoltcp"
),
Err(err) => tracing::trace!(
?ipv4_src,
?err,
entry_count = self.entry_count.load(Ordering::Relaxed),
"socks5 failed to deliver fragmented packet from peer to smoltcp"
),
}
} }
return Some(packet); return Some(packet);
} }
@@ -570,14 +787,17 @@ impl PeerPacketFilter for Socks5Server {
let Some(udp_packet) = UdpPacket::new(ipv4.payload()) else { let Some(udp_packet) = UdpPacket::new(ipv4.payload()) else {
return Some(packet); return Some(packet);
}; };
Socks5Entry { (
dst: SocketAddr::new(ipv4.get_source().into(), udp_packet.get_source()), Socks5Entry {
src: SocketAddr::new( dst: SocketAddr::new(ipv4.get_source().into(), udp_packet.get_source()),
ipv4.get_destination().into(), src: SocketAddr::new(
udp_packet.get_destination(), ipv4.get_destination().into(),
), udp_packet.get_destination(),
entry_type: UDP_ENTRY, ),
} entry_type: UDP_ENTRY,
},
None,
)
} }
_ => { _ => {
return Some(packet); return Some(packet);
@@ -585,12 +805,41 @@ impl PeerPacketFilter for Socks5Server {
}; };
if !self.entries.contains_key(&entry_key) { if !self.entries.contains_key(&entry_key) {
tracing::trace!(
?entry_key,
?tcp_flags,
ipv4_src = %ipv4.get_source(),
ipv4_dst = %ipv4.get_destination(),
entry_count = self.entry_count.load(Ordering::Relaxed),
socks5_enabled = self.socks5_enabled.load(Ordering::Relaxed),
"socks5 no entry for packet from peer"
);
return Some(packet); return Some(packet);
} }
tracing::trace!(?entry_key, ?ipv4, "socks5 found entry for packet from peer"); tracing::trace!(
?entry_key,
?tcp_flags,
?ipv4,
entry_count = self.entry_count.load(Ordering::Relaxed),
"socks5 found entry for packet from peer"
);
let _ = self.packet_sender.try_send(packet).ok(); match self.packet_sender.try_send(packet) {
Ok(()) => tracing::trace!(
?entry_key,
?tcp_flags,
entry_count = self.entry_count.load(Ordering::Relaxed),
"socks5 delivered packet from peer to smoltcp"
),
Err(err) => tracing::trace!(
?entry_key,
?tcp_flags,
?err,
entry_count = self.entry_count.load(Ordering::Relaxed),
"socks5 failed to deliver packet from peer to smoltcp"
),
}
None None
} }
@@ -655,11 +904,22 @@ impl Socks5Server {
#[cfg(not(feature = "ffi-dataplane"))] #[cfg(not(feature = "ffi-dataplane"))]
let data_plane_active = false; let data_plane_active = false;
if cancel_tokens.is_empty() let active_port_forwards = cancel_tokens.len();
&& !socks5_enabled.load(Ordering::Relaxed) let is_socks5_enabled = socks5_enabled.load(Ordering::Relaxed);
&& !data_plane_active if active_port_forwards == 0 && !is_socks5_enabled && !data_plane_active {
{ let had_net = {
let _ = net.lock().await.take(); let mut net_guard = net.lock().await;
net_guard.take().is_some()
};
tracing::trace!(
had_net,
active_port_forwards,
is_socks5_enabled,
data_plane_active,
entry_count = entry_count.load(Ordering::Relaxed),
entries_len = entries.len(),
"socks5 net update waiting for consumers"
);
#[cfg(feature = "ffi-dataplane")] #[cfg(feature = "ffi-dataplane")]
let _ = data_plane_net_ready.send_replace(false); let _ = data_plane_net_ready.send_replace(false);
port_forward_list_change_notifier.notified().await; port_forward_list_change_notifier.notified().await;
@@ -670,13 +930,34 @@ impl Socks5Server {
let cur_ipv4 = global_ctx.get_ipv4(); let cur_ipv4 = global_ctx.get_ipv4();
if prev_ipv4 != cur_ipv4 { if prev_ipv4 != cur_ipv4 {
let old_ipv4 = prev_ipv4;
prev_ipv4 = cur_ipv4; prev_ipv4 = cur_ipv4;
tracing::trace!(
?old_ipv4,
?cur_ipv4,
old_entry_count = entry_count.load(Ordering::Relaxed),
old_entries_len = entries.len(),
udp_client_count = udp_client_map.len(),
"socks5 net update resetting entries for ipv4 change"
);
let mut removed_entries = 0;
entries.retain(|_, _| { entries.retain(|_, _| {
entry_count.fetch_sub(1, Ordering::Relaxed); removed_entries += 1;
false false
}); });
let (_, new_entry_count) =
decrement_entry_count_by(&entry_count, removed_entries);
udp_client_map.clear(); udp_client_map.clear();
tracing::trace!(
?old_ipv4,
?cur_ipv4,
removed_entries,
new_entry_count,
new_entries_len = entries.len(),
udp_client_count = udp_client_map.len(),
"socks5 net update reset entries complete"
);
if let Some(cur_ipv4) = cur_ipv4 { if let Some(cur_ipv4) = cur_ipv4 {
net.lock().await.replace(Socks5ServerNet::new( net.lock().await.replace(Socks5ServerNet::new(
@@ -686,12 +967,23 @@ impl Socks5Server {
packet_recv.clone(), packet_recv.clone(),
entries.clone(), entries.clone(),
)); ));
tracing::trace!(
?cur_ipv4,
entry_count = entry_count.load(Ordering::Relaxed),
entries_len = entries.len(),
"socks5 net update installed smoltcp net"
);
// Wake any data-plane callers waiting in // Wake any data-plane callers waiting in
// `wait_data_plane_net` for the smoltcp net to appear. // `wait_data_plane_net` for the smoltcp net to appear.
#[cfg(feature = "ffi-dataplane")] #[cfg(feature = "ffi-dataplane")]
let _ = data_plane_net_ready.send_replace(true); let _ = data_plane_net_ready.send_replace(true);
} else { } else {
let _ = net.lock().await.take(); let _ = net.lock().await.take();
tracing::trace!(
entry_count = entry_count.load(Ordering::Relaxed),
entries_len = entries.len(),
"socks5 net update removed smoltcp net"
);
#[cfg(feature = "ffi-dataplane")] #[cfg(feature = "ffi-dataplane")]
let _ = data_plane_net_ready.send_replace(false); let _ = data_plane_net_ready.send_replace(false);
} }
@@ -774,6 +1066,13 @@ impl Socks5Server {
peer_manager peer_manager
.add_packet_process_pipeline(Box::new(self.clone())) .add_packet_process_pipeline(Box::new(self.clone()))
.await; .await;
tracing::trace!(
cfg_count = cfgs.len(),
cancel_token_count = self.cancel_tokens.len(),
entry_count = self.entry_count.load(Ordering::Relaxed),
entries_len = self.entries.len(),
"socks5 peer packet pipeline registered"
);
self.run_net_update_task().await; self.run_net_update_task().await;
@@ -806,6 +1105,7 @@ impl Socks5Server {
connector: Box<dyn AsyncTcpConnector<S = SocksTcpStream> + Send>, connector: Box<dyn AsyncTcpConnector<S = SocksTcpStream> + Send>,
dst_addr: SocketAddr, dst_addr: SocketAddr,
) { ) {
tracing::trace!(?dst_addr, "port forward: connecting to destination");
let outgoing_socket = match connector.tcp_connect(dst_addr, 10).await { let outgoing_socket = match connector.tcp_connect(dst_addr, 10).await {
Ok(socket) => socket, Ok(socket) => socket,
Err(e) => { Err(e) => {
@@ -813,6 +1113,7 @@ impl Socks5Server {
return; return;
} }
}; };
tracing::trace!(?dst_addr, "port forward: connected to destination");
let mut outgoing_socket = outgoing_socket; let mut outgoing_socket = outgoing_socket;
match tokio::io::copy_bidirectional(&mut incoming_socket, &mut outgoing_socket).await { match tokio::io::copy_bidirectional(&mut incoming_socket, &mut outgoing_socket).await {
@@ -899,12 +1200,30 @@ impl Socks5Server {
dst_addr dst_addr
); );
let (smoltcp_net, net_ipv4) = {
let net_guard = net.lock().await;
(
net_guard.as_ref().map(|net| net.smoltcp_net.clone()),
net_guard.as_ref().map(|net| net.ipv4_addr),
)
};
tracing::trace!(
?bind_addr,
?dst_addr,
client_addr = ?addr,
has_smoltcp_net = smoltcp_net.is_some(),
?net_ipv4,
entry_count = entry_count.load(Ordering::Relaxed),
entries_len = entries.len(),
"port forward: preparing connector"
);
let connector = Socks5AutoConnector { let connector = Socks5AutoConnector {
#[cfg(feature = "kcp")] #[cfg(feature = "kcp")]
kcp_endpoint: kcp_endpoint.clone(), kcp_endpoint: kcp_endpoint.clone(),
peer_mgr: peer_mgr.clone(), peer_mgr: peer_mgr.clone(),
entries: entries.clone(), entries: entries.clone(),
smoltcp_net: net.lock().await.as_ref().map(|net| net.smoltcp_net.clone()), smoltcp_net,
src_addr: addr, src_addr: addr,
entry_count: entry_count.clone(), entry_count: entry_count.clone(),
inner_connector: parking_lot::Mutex::new(None), inner_connector: parking_lot::Mutex::new(None),
@@ -1040,11 +1359,12 @@ impl Socks5Server {
) )
}; };
let socks_udp = Arc::new(sokcs_udp); let socks_udp = Arc::new(sokcs_udp);
entries.insert( insert_entry_and_increment_count(
&entries,
&entry_count,
client_info.entry_key.clone(), client_info.entry_key.clone(),
Socks5EntryData::Udp((socks_udp.clone(), udp_client_key.clone())), Socks5EntryData::Udp((socks_udp.clone(), udp_client_key.clone())),
); );
entry_count.fetch_add(1, Ordering::Relaxed);
let socks = socket.clone(); let socks = socket.clone();
let client_addr = addr; let client_addr = addr;
@@ -1107,16 +1427,18 @@ impl Socks5Server {
now.duration_since(client_info.last_active.load()).as_secs() < 600 now.duration_since(client_info.last_active.load()).as_secs() < 600
}); });
udp_forward_task.retain(|k, _| udp_client_map.contains_key(k)); udp_forward_task.retain(|k, _| udp_client_map.contains_key(k));
let mut removed_entries = 0;
entries.retain(|_, data| match data { entries.retain(|_, data| match data {
Socks5EntryData::Udp((_, udp_client_key)) => { Socks5EntryData::Udp((_, udp_client_key)) => {
let keep = udp_client_map.contains_key(udp_client_key); let keep = udp_client_map.contains_key(udp_client_key);
if !keep { if !keep {
entry_count.fetch_sub(1, Ordering::Relaxed); removed_entries += 1;
} }
keep keep
} }
_ => true, _ => true,
}); });
decrement_entry_count_by(&entry_count, removed_entries);
udp_client_map.shrink_to_fit(); udp_client_map.shrink_to_fit();
udp_forward_task.shrink_to_fit(); udp_forward_task.shrink_to_fit();
@@ -1128,3 +1450,324 @@ impl Socks5Server {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use pnet::packet::{
MutablePacket,
ip::IpNextHeaderProtocols,
ipv4::{self, MutableIpv4Packet},
tcp::{self, MutableTcpPacket, TcpFlags},
};
use super::*;
use crate::peers::tests::create_mock_peer_manager;
fn build_tcp_packet(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
let mut buf = vec![0u8; 40];
let src_ip = match src.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => panic!("test only supports ipv4"),
};
let dst_ip = match dst.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => panic!("test only supports ipv4"),
};
{
let mut ip_packet = MutableIpv4Packet::new(&mut buf).unwrap();
ip_packet.set_version(4);
ip_packet.set_header_length(5);
ip_packet.set_total_length(40);
ip_packet.set_ttl(64);
ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
ip_packet.set_source(src_ip);
ip_packet.set_destination(dst_ip);
let mut tcp_packet = MutableTcpPacket::new(ip_packet.payload_mut()).unwrap();
tcp_packet.set_source(src.port());
tcp_packet.set_destination(dst.port());
tcp_packet.set_data_offset(5);
tcp_packet.set_flags(TcpFlags::SYN | TcpFlags::ACK);
tcp_packet.set_window(65535);
tcp_packet.set_checksum(tcp::ipv4_checksum(
&tcp_packet.to_immutable(),
&src_ip,
&dst_ip,
));
ip_packet.set_checksum(ipv4::checksum(&ip_packet.to_immutable()));
}
buf
}
fn build_udp_followup_fragment(src: Ipv4Addr, dst: Ipv4Addr) -> Vec<u8> {
let mut buf = vec![0u8; 28];
{
let mut ip_packet = MutableIpv4Packet::new(&mut buf).unwrap();
ip_packet.set_version(4);
ip_packet.set_header_length(5);
ip_packet.set_total_length(28);
ip_packet.set_ttl(64);
ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Udp);
ip_packet.set_fragment_offset(1);
ip_packet.set_source(src);
ip_packet.set_destination(dst);
ip_packet
.payload_mut()
.copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]);
ip_packet.set_checksum(ipv4::checksum(&ip_packet.to_immutable()));
}
buf
}
#[tokio::test]
async fn socks5_consumes_modified_data_when_entry_matches() {
let peer_manager = create_mock_peer_manager().await;
let server = Socks5Server::new(peer_manager.get_global_ctx(), peer_manager, None);
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 22);
let entry = Socks5Entry {
src: local,
dst: remote,
entry_type: TCP_ENTRY,
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
insert_entry_and_increment_count(
&server.entries,
&server.entry_count,
entry,
Socks5EntryData::Tcp(listener),
);
for packet_type in [
PacketType::DataWithKcpSrcModified,
PacketType::DataWithQuicSrcModified,
] {
let mut packet = ZCPacket::new_with_payload(&build_tcp_packet(remote, local));
packet.fill_peer_manager_hdr(1, 1, packet_type as u8);
let result = server.try_process_packet_from_peer(packet).await;
assert!(result.is_none());
let mut receiver = server.packet_recv.lock().await;
let received = receiver.try_recv().unwrap();
assert_eq!(
received.peer_manager_header().unwrap().packet_type,
packet_type as u8
);
}
}
#[tokio::test]
async fn socks5_passes_through_unmatched_or_malformed_modified_data() {
let peer_manager = create_mock_peer_manager().await;
let server = Socks5Server::new(peer_manager.get_global_ctx(), peer_manager, None);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
insert_entry_and_increment_count(
&server.entries,
&server.entry_count,
Socks5Entry {
src: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000),
dst: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 22),
entry_type: TCP_ENTRY,
},
Socks5EntryData::Tcp(listener),
);
let unmatched_local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40001);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 22);
let mut unmatched_packet =
ZCPacket::new_with_payload(&build_tcp_packet(remote, unmatched_local));
unmatched_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
let result = server.try_process_packet_from_peer(unmatched_packet).await;
assert!(result.is_some());
let mut malformed_packet = ZCPacket::new_with_payload(&[0u8; 8]);
malformed_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithQuicSrcModified as u8);
let result = server.try_process_packet_from_peer(malformed_packet).await;
assert!(result.is_some());
let mut receiver = server.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn socks5_passes_through_non_loopback_modified_data_even_when_entry_matches() {
let peer_manager = create_mock_peer_manager().await;
let server = Socks5Server::new(peer_manager.get_global_ctx(), peer_manager, None);
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 22);
let entry = Socks5Entry {
src: local,
dst: remote,
entry_type: TCP_ENTRY,
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
insert_entry_and_increment_count(
&server.entries,
&server.entry_count,
entry,
Socks5EntryData::Tcp(listener),
);
let mut packet = ZCPacket::new_with_payload(&build_tcp_packet(remote, local));
packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
let result = server.try_process_packet_from_peer(packet).await;
assert!(result.is_some());
let mut receiver = server.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn socks5_mirrors_fragmented_udp_even_when_entry_count_is_stale_zero() {
let peer_manager = create_mock_peer_manager().await;
let server = Socks5Server::new(peer_manager.get_global_ctx(), peer_manager, None);
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
let udp_socket = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
server.entries.insert(
Socks5Entry {
src: local,
dst: remote,
entry_type: UDP_ENTRY,
},
Socks5EntryData::Udp((
Arc::new(SocksUdpSocket::UdpSocket(udp_socket)),
UdpClientKey {
client_addr: local,
dst_addr: remote,
},
)),
);
assert_eq!(server.entry_count.load(Ordering::Relaxed), 0);
let mut packet = ZCPacket::new_with_payload(&build_udp_followup_fragment(
match remote.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => unreachable!(),
},
match local.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => unreachable!(),
},
));
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
let result = server.try_process_packet_from_peer(packet).await;
assert!(result.is_some());
let mut receiver = server.packet_recv.lock().await;
let received = receiver.try_recv().unwrap();
assert_eq!(
received.peer_manager_header().unwrap().packet_type,
PacketType::Data as u8
);
}
#[test]
fn decrement_entry_count_does_not_underflow() {
let entry_count = AtomicUsize::new(0);
let (old_entry_count, new_entry_count) = decrement_entry_count(&entry_count);
assert_eq!(old_entry_count, 0);
assert_eq!(new_entry_count, 0);
assert_eq!(entry_count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn removing_missing_entry_does_not_decrement_entry_count() {
let entries = Arc::new(DashMap::new());
let entry_count = AtomicUsize::new(1);
let entry = Socks5Entry {
src: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 2)), 40000),
dst: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)), 22),
entry_type: TCP_ENTRY,
};
let (removed, old_entry_count, new_entry_count) =
remove_entry_and_decrement_count(&entries, &entry_count, &entry);
assert!(!removed);
assert_eq!(old_entry_count, 1);
assert_eq!(new_entry_count, 1);
assert_eq!(entry_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn removing_present_entry_decrements_entry_count_once() {
let entries = Arc::new(DashMap::new());
let entry_count = AtomicUsize::new(0);
let entry = Socks5Entry {
src: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 2)), 40000),
dst: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)), 22),
entry_type: TCP_ENTRY,
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
insert_entry_and_increment_count(
&entries,
&entry_count,
entry.clone(),
Socks5EntryData::Tcp(listener),
);
let (removed, old_entry_count, new_entry_count) =
remove_entry_and_decrement_count(&entries, &entry_count, &entry);
let (removed_again, old_entry_count_again, new_entry_count_again) =
remove_entry_and_decrement_count(&entries, &entry_count, &entry);
assert!(removed);
assert_eq!(old_entry_count, 1);
assert_eq!(new_entry_count, 0);
assert!(!removed_again);
assert_eq!(old_entry_count_again, 0);
assert_eq!(new_entry_count_again, 0);
assert_eq!(entry_count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn replacing_present_entry_does_not_increment_entry_count() {
let entries = Arc::new(DashMap::new());
let entry_count = AtomicUsize::new(0);
let entry = Socks5Entry {
src: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 2)), 40000),
dst: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)), 22),
entry_type: TCP_ENTRY,
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let replacement = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let (replaced, old_entry_count, new_entry_count) = insert_entry_and_increment_count(
&entries,
&entry_count,
entry.clone(),
Socks5EntryData::Tcp(listener),
);
let (replaced_again, old_entry_count_again, new_entry_count_again) =
insert_entry_and_increment_count(
&entries,
&entry_count,
entry,
Socks5EntryData::Tcp(replacement),
);
assert!(!replaced);
assert_eq!(old_entry_count, 0);
assert_eq!(new_entry_count, 1);
assert!(replaced_again);
assert_eq!(old_entry_count_again, 1);
assert_eq!(new_entry_count_again, 1);
assert_eq!(entry_count.load(Ordering::Relaxed), 1);
}
}
+26 -21
View File
@@ -26,8 +26,7 @@ use std::{
}; };
use anyhow::Context as _; use anyhow::Context as _;
use dashmap::mapref::entry::Entry; use quanta::Instant;
use hotpath::instant::Instant;
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use crate::{common::error::Error, gateway::fast_socks5::server::AsyncTcpConnector}; use crate::{common::error::Error, gateway::fast_socks5::server::AsyncTcpConnector};
@@ -35,6 +34,7 @@ use crate::{common::error::Error, gateway::fast_socks5::server::AsyncTcpConnecto
use super::{ use super::{
Socks5AutoConnector, Socks5Entry, Socks5EntryData, Socks5EntrySet, Socks5Server, Socks5AutoConnector, Socks5Entry, Socks5EntryData, Socks5EntrySet, Socks5Server,
SocksTcpStream, SocksUdpSocket, TCP_ENTRY, TCP_LISTEN_ENTRY, UDP_ENTRY, UdpClientKey, SocksTcpStream, SocksUdpSocket, TCP_ENTRY, TCP_LISTEN_ENTRY, UDP_ENTRY, UdpClientKey,
decrement_entry_count, insert_entry_and_increment_count, try_insert_entry_and_increment_count,
}; };
use crate::gateway::tokio_smoltcp::{Net, TcpListener}; use crate::gateway::tokio_smoltcp::{Net, TcpListener};
@@ -59,12 +59,12 @@ impl OwnedRouteEntry {
entry_count: Arc<AtomicUsize>, entry_count: Arc<AtomicUsize>,
entry: Socks5Entry, entry: Socks5Entry,
) -> Self { ) -> Self {
if entries insert_entry_and_increment_count(
.insert(entry.clone(), Socks5EntryData::DataPlaneRoute) &entries,
.is_none() &entry_count,
{ entry.clone(),
entry_count.fetch_add(1, Ordering::Relaxed); Socks5EntryData::DataPlaneRoute,
} );
Self { Self {
entries, entries,
entry_count, entry_count,
@@ -78,12 +78,13 @@ impl OwnedRouteEntry {
entry_count: Arc<AtomicUsize>, entry_count: Arc<AtomicUsize>,
entry: Socks5Entry, entry: Socks5Entry,
) -> Option<Self> { ) -> Option<Self> {
match entries.entry(entry.clone()) { if !try_insert_entry_and_increment_count(
Entry::Occupied(_) => return None, &entries,
Entry::Vacant(vacant) => { &entry_count,
vacant.insert(Socks5EntryData::DataPlaneRoute); entry.clone(),
entry_count.fetch_add(1, Ordering::Relaxed); Socks5EntryData::DataPlaneRoute,
} ) {
return None;
} }
Some(Self { Some(Self {
entries, entries,
@@ -96,7 +97,7 @@ impl OwnedRouteEntry {
impl Drop for OwnedRouteEntry { impl Drop for OwnedRouteEntry {
fn drop(&mut self) { fn drop(&mut self) {
if self.entries.remove(&self.entry).is_some() { if self.entries.remove(&self.entry).is_some() {
self.entry_count.fetch_sub(1, Ordering::Relaxed); decrement_entry_count(&self.entry_count);
} }
} }
} }
@@ -224,16 +225,18 @@ impl DataPlaneUdpSocket {
dst: addr, dst: addr,
entry_type: UDP_ENTRY, entry_type: UDP_ENTRY,
}; };
if let Entry::Vacant(entry) = self.entries.entry(key) { try_insert_entry_and_increment_count(
entry.insert(Socks5EntryData::Udp(( &self.entries,
&self.entry_count,
key,
Socks5EntryData::Udp((
self.socket.clone(), self.socket.clone(),
UdpClientKey { UdpClientKey {
client_addr: self.local_addr, client_addr: self.local_addr,
dst_addr: addr, dst_addr: addr,
}, },
))); )),
self.entry_count.fetch_add(1, Ordering::Relaxed); );
}
self.socket.send_to(buf, addr).await self.socket.send_to(buf, addr).await
} }
@@ -244,13 +247,15 @@ impl DataPlaneUdpSocket {
impl Drop for DataPlaneUdpSocket { impl Drop for DataPlaneUdpSocket {
fn drop(&mut self) { fn drop(&mut self) {
let mut removed_entries = 0;
self.entries.retain(|_, data| match data { self.entries.retain(|_, data| match data {
Socks5EntryData::Udp((socket, _)) if Arc::ptr_eq(socket, &self.socket) => { Socks5EntryData::Udp((socket, _)) if Arc::ptr_eq(socket, &self.socket) => {
self.entry_count.fetch_sub(1, Ordering::Relaxed); removed_entries += 1;
false false
} }
_ => true, _ => true,
}); });
super::decrement_entry_count_by(&self.entry_count, removed_entries);
} }
} }
+1 -1
View File
@@ -3,12 +3,12 @@ use cidr::Ipv4Inet;
use core::panic; use core::panic;
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::MutablePacket; use pnet::packet::MutablePacket;
use pnet::packet::Packet; use pnet::packet::Packet;
use pnet::packet::ip::IpNextHeaderProtocols; use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::ipv4::{Ipv4Packet, MutableIpv4Packet}; use pnet::packet::ipv4::{Ipv4Packet, MutableIpv4Packet};
use pnet::packet::tcp::{MutableTcpPacket, TcpPacket, ipv4_checksum}; use pnet::packet::tcp::{MutableTcpPacket, TcpPacket, ipv4_checksum};
use quanta::Instant;
use socket2::{SockRef, TcpKeepalive}; use socket2::{SockRef, TcpKeepalive};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::atomic::{AtomicBool, AtomicU16}; use std::sync::atomic::{AtomicBool, AtomicU16};
+1 -1
View File
@@ -8,13 +8,13 @@ use bytes::{BufMut, BytesMut};
use cidr::Ipv4Inet; use cidr::Ipv4Inet;
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::{ use pnet::packet::{
Packet, Packet,
ip::IpNextHeaderProtocols, ip::IpNextHeaderProtocols,
ipv4::Ipv4Packet, ipv4::Ipv4Packet,
udp::{self, MutableUdpPacket}, udp::{self, MutableUdpPacket},
}; };
use quanta::Instant;
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError}; use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError};
use tokio::{ use tokio::{
net::UdpSocket, net::UdpSocket,
-40
View File
@@ -1,40 +0,0 @@
//! No-op stand-in for the `hotpath` macros used by this crate, selected when
//! the `hotpath` feature is disabled.
//!
//! Keeping `hotpath` as an optional dependency means default builds do not pull
//! the profiler (or any of its transitive dependencies) into the dependency
//! graph. These macros expand to their input unchanged, mirroring `hotpath`'s
//! own disabled mode so call sites compile identically with or without the
//! feature.
//!
//! The macros are `#[macro_export]`-ed so that `lib.rs`' `extern crate self as
//! hotpath` alias exposes them through the same `hotpath::...` paths used when
//! the feature is enabled.
/// No-op mirroring `hotpath::channel!`: returns the channel expression
/// unchanged (dropping any optional trailing `label`/`log`/`capacity` args).
#[doc(hidden)]
#[macro_export]
macro_rules! channel {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
/// No-op mirroring `hotpath::mutex!`: returns the expression unchanged.
#[doc(hidden)]
#[macro_export]
macro_rules! mutex {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
/// No-op mirroring `hotpath::rw_lock!`: returns the expression unchanged.
#[doc(hidden)]
#[macro_export]
macro_rules! rw_lock {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
+10 -6
View File
@@ -483,18 +483,22 @@ impl InstanceConfigPatcher {
} }
let global_ctx = weak_upgrade(&self.global_ctx)?; let global_ctx = weak_upgrade(&self.global_ctx)?;
for proxy_network_patch in proxy_networks { for proxy_network_patch in proxy_networks {
let Some(cidr) = proxy_network_patch.cidr.map(|c| c.into()) else {
tracing::warn!("Proxy network cidr is None, skipping.");
continue;
};
let mapped_cidr: Option<cidr::Ipv4Cidr> =
proxy_network_patch.mapped_cidr.map(|s| s.into());
match ConfigPatchAction::try_from(proxy_network_patch.action) { match ConfigPatchAction::try_from(proxy_network_patch.action) {
Ok(ConfigPatchAction::Add) => { Ok(ConfigPatchAction::Add) => {
let Some(cidr) = proxy_network_patch.cidr.map(|c| c.into()) else {
tracing::warn!("Proxy network cidr is None, skipping add.");
continue;
};
let mapped_cidr: Option<cidr::Ipv4Cidr> =
proxy_network_patch.mapped_cidr.map(|s| s.into());
tracing::info!("Proxy network added: {}", cidr); tracing::info!("Proxy network added: {}", cidr);
global_ctx.config.add_proxy_cidr(cidr, mapped_cidr)?; global_ctx.config.add_proxy_cidr(cidr, mapped_cidr)?;
} }
Ok(ConfigPatchAction::Remove) => { Ok(ConfigPatchAction::Remove) => {
let Some(cidr) = proxy_network_patch.cidr.map(|c| c.into()) else {
tracing::warn!("Proxy network cidr is None, skipping remove.");
continue;
};
tracing::info!("Proxy network removed: {}", cidr); tracing::info!("Proxy network removed: {}", cidr);
global_ctx.config.remove_proxy_cidr(cidr); global_ctx.config.remove_proxy_cidr(cidr);
} }
+1 -1
View File
@@ -3,7 +3,7 @@ use std::sync::{Arc, Weak};
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent}; use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent};
use crate::peers::peer_manager::PeerManager; use crate::peers::peer_manager::PeerManager;
use hotpath::instant::Instant; use quanta::Instant;
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
/// ProxyCidrsMonitor monitors changes in proxy CIDRs from peer routes /// ProxyCidrsMonitor monitors changes in proxy CIDRs from peer routes
+3 -4
View File
@@ -24,7 +24,7 @@ use crate::{
}; };
use byteorder::WriteBytesExt as _; use byteorder::WriteBytesExt as _;
use bytes::{Buf, BufMut, BytesMut}; use bytes::{BufMut, BytesMut};
use cidr::{Ipv4Inet, Ipv6Inet}; use cidr::{Ipv4Inet, Ipv6Inet};
use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready}; use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready};
use pin_project_lite::pin_project; use pin_project_lite::pin_project;
@@ -180,13 +180,12 @@ impl ZCPacketToBytes for TunZCPacketToBytes {
assert!(payload_offset >= 4); assert!(payload_offset >= 4);
let ret = if self.has_packet_info { let ret = if self.has_packet_info {
inner.advance(payload_offset - 4); let mut inner = inner.split_off(payload_offset - 4);
let proto = infer_proto(&inner[4..]); let proto = infer_proto(&inner[4..]);
self.fill_packet_info(&mut inner[0..4], proto)?; self.fill_packet_info(&mut inner[0..4], proto)?;
inner inner
} else { } else {
inner.advance(payload_offset); inner.split_off(payload_offset)
inner
}; };
tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet"); tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet");
+151 -19
View File
@@ -626,6 +626,47 @@ pub type NetworkingMethod = crate::proto::api::manage::NetworkingMethod;
pub type NetworkConfig = crate::proto::api::manage::NetworkConfig; pub type NetworkConfig = crate::proto::api::manage::NetworkConfig;
impl NetworkConfig { impl NetworkConfig {
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
let uri = peer.uri.trim();
if uri.is_empty() {
return Ok(None);
}
Ok(Some(PeerConfig {
uri: uri
.parse()
.with_context(|| format!("failed to parse peer uri: {}", uri))?,
peer_public_key: peer.peer_public_key.clone(),
}))
}
fn parse_peers(peers: &[manage::NetworkPeerConfig]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut ret = Vec::new();
for peer in peers {
if let Some(peer) = Self::parse_peer(peer)? {
ret.push(peer);
}
}
Ok(ret)
}
fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut peers = vec![];
for peer_url in peer_urls.iter() {
let peer_url = peer_url.trim();
if peer_url.is_empty() {
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
peer_public_key: None,
});
}
Ok(peers)
}
pub fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> { pub fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
let cfg = TomlConfigLoader::default(); let cfg = TomlConfigLoader::default();
cfg.set_id( cfg.set_id(
@@ -681,26 +722,23 @@ impl NetworkConfig {
.unwrap_or_default() .unwrap_or_default()
{ {
NetworkingMethod::PublicServer => { NetworkingMethod::PublicServer => {
let public_server_url = self.public_server_url.clone().unwrap_or_default(); let peers = Self::parse_peers(&self.peers)?;
cfg.set_peers(vec![PeerConfig { if peers.is_empty() {
uri: public_server_url.parse().with_context(|| { let public_server_url = self.public_server_url.clone().unwrap_or_default();
format!("failed to parse public server uri: {}", public_server_url) cfg.set_peers(vec![PeerConfig {
})?, uri: public_server_url.parse().with_context(|| {
peer_public_key: None, format!("failed to parse public server uri: {}", public_server_url)
}]); })?,
peer_public_key: None,
}]);
} else {
cfg.set_peers(peers);
}
} }
NetworkingMethod::Manual => { NetworkingMethod::Manual => {
let mut peers = vec![]; let mut peers = Self::parse_peers(&self.peers)?;
for peer_url in self.peer_urls.iter() { if peers.is_empty() {
if peer_url.is_empty() { peers = Self::parse_peer_urls(&self.peer_urls)?;
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
peer_public_key: None,
});
} }
if !peers.is_empty() { if !peers.is_empty() {
cfg.set_peers(peers); cfg.set_peers(peers);
@@ -1044,6 +1082,13 @@ impl NetworkConfig {
result.networking_method = Some(NetworkingMethod::Manual as i32); result.networking_method = Some(NetworkingMethod::Manual as i32);
if !peers.is_empty() { if !peers.is_empty() {
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect(); result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
result.peers = peers
.iter()
.map(|p| manage::NetworkPeerConfig {
uri: p.uri.to_string(),
peer_public_key: p.peer_public_key.clone(),
})
.collect();
} }
result.listener_urls = config result.listener_urls = config
@@ -1116,6 +1161,7 @@ impl NetworkConfig {
.get_credential_file() .get_credential_file()
.map(|path| path.to_string_lossy().into_owned()); .map(|path| path.to_string_lossy().into_owned());
let flags = config.get_flags(); let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first); result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone()); result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp); result.use_smoltcp = Some(flags.use_smoltcp);
@@ -1144,6 +1190,11 @@ impl NetworkConfig {
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching); result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns); result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32); result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm.clone());
result.instance_recv_bps_limit = result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit); (flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode); result.enable_private_mode = Some(flags.private_mode);
@@ -1173,7 +1224,7 @@ impl NetworkConfig {
mod tests { mod tests {
use crate::{ use crate::{
common::config::{ConfigLoader, process_secure_mode_cfg}, common::config::{ConfigLoader, process_secure_mode_cfg},
proto::common::SecureModeConfig, proto::common::{CompressionAlgoPb, SecureModeConfig},
}; };
use base64::prelude::{BASE64_STANDARD, Engine as _}; use base64::prelude::{BASE64_STANDARD, Engine as _};
use rand::Rng; use rand::Rng;
@@ -1236,6 +1287,54 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn test_network_config_conversion_preserves_peer_public_key() -> Result<(), anyhow::Error> {
let peer_url = "tcp://1.2.3.4:11010";
let peer_public_key = BASE64_STANDARD.encode([9u8; 32]);
let config = gen_default_config();
config.set_peers(vec![crate::common::config::PeerConfig {
uri: peer_url.parse()?,
peer_public_key: Some(peer_public_key.clone()),
}]);
let network_config = super::NetworkConfig::new_from_config(&config)?;
assert_eq!(network_config.peer_urls, vec![peer_url.to_string()]);
assert_eq!(network_config.peers.len(), 1);
assert_eq!(network_config.peers[0].uri, peer_url);
assert_eq!(
network_config.peers[0].peer_public_key.as_deref(),
Some(peer_public_key.as_str())
);
let generated_config = network_config.gen_config()?;
assert_eq!(generated_config.get_peers(), config.get_peers());
Ok(())
}
#[test]
fn network_config_gen_config_trims_legacy_peer_urls() -> Result<(), anyhow::Error> {
let network_config = super::NetworkConfig {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
networking_method: Some(crate::proto::api::manage::NetworkingMethod::Manual as i32),
peer_urls: vec![
" tcp://1.2.3.4:11010 ".to_string(),
" ".to_string(),
"\tudp://5.6.7.8:11010\n".to_string(),
],
..Default::default()
};
let generated_config = network_config.gen_config()?;
let peers = generated_config.get_peers();
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].uri.as_str(), "tcp://1.2.3.4:11010");
assert_eq!(peers[1].uri.as_str(), "udp://5.6.7.8:11010");
Ok(())
}
#[test] #[test]
fn test_network_config_conversion_random() -> Result<(), anyhow::Error> { fn test_network_config_conversion_random() -> Result<(), anyhow::Error> {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
@@ -1534,4 +1633,37 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn test_network_config_conversion_preserves_runtime_algorithm_flags()
-> Result<(), anyhow::Error> {
let config = gen_default_config();
let mut flags = config.get_flags();
flags.data_compress_algo = CompressionAlgoPb::Zstd.into();
flags.encryption_algorithm = "managed-test-algo".to_string();
config.set_flags(flags.clone());
let network_config = super::NetworkConfig::new_from_config(&config)?;
assert_eq!(
network_config.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
assert_eq!(
network_config.encryption_algorithm.as_deref(),
Some("managed-test-algo")
);
let generated_config = network_config.gen_config()?;
assert_eq!(
generated_config.get_flags().data_compress_algo,
flags.data_compress_algo
);
assert_eq!(
generated_config.get_flags().encryption_algorithm,
flags.encryption_algorithm
);
Ok(())
}
} }
+1 -20
View File
@@ -5,29 +5,10 @@ use std::io;
use clap::Command; use clap::Command;
use clap_complete::{Generator, Shell}; use clap_complete::{Generator, Shell};
// When the `hotpath` feature is off, alias the current crate as `hotpath` so
// call sites keep using `hotpath::...` paths, and provide a local no-op shim
// for the profiling macros. This keeps `hotpath` an optional dependency: the
// profiler is absent from the dependency graph entirely in default builds.
#[cfg(not(feature = "hotpath"))]
extern crate self as hotpath;
#[cfg(not(feature = "hotpath"))]
mod hotpath_off;
// When the `hotpath` feature is off, expose a local `instant` module backed by
// `quanta::Instant` so call sites can uniformly write `use hotpath::instant::Instant;`
// regardless of whether the feature is enabled. With the feature on, the real
// `hotpath` crate provides the same path (also `quanta::Instant` on Linux), so
// the two modes resolve to the identical type.
#[cfg(not(feature = "hotpath"))]
pub mod instant {
pub type Instant = quanta::Instant;
}
// Re-export `Instant` at the crate root so public APIs that expose it // Re-export `Instant` at the crate root so public APIs that expose it
// (e.g. `Route::get_peer_info_last_update_time`) reference a deliberate // (e.g. `Route::get_peer_info_last_update_time`) reference a deliberate
// public type rather than leaking an inaccessible one. // public type rather than leaking an inaccessible one.
pub use hotpath::instant::Instant; pub use quanta::Instant;
mod arch; mod arch;
mod gateway; mod gateway;
+2 -2
View File
@@ -7,11 +7,11 @@ use std::{
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::ipv6::Ipv6Packet; use pnet::packet::ipv6::Ipv6Packet;
use pnet::packet::{ use pnet::packet::{
Packet as _, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket, Packet as _, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket,
}; };
use quanta::Instant;
use crate::proto::acl::{AclStats, Protocol}; use crate::proto::acl::{AclStats, Protocol};
use crate::tunnel::packet_def::PacketType; use crate::tunnel::packet_def::PacketType;
@@ -404,7 +404,7 @@ mod tests {
sync::Arc, sync::Arc,
}; };
use hotpath::instant::Instant; use quanta::Instant;
use crate::{ use crate::{
common::acl_processor::PacketInfo, common::acl_processor::PacketInfo,
+5 -11
View File
@@ -59,21 +59,15 @@ type BoxNicPacketFilter = Box<dyn NicPacketFilter + Send + Sync>;
pub type PacketRecvChan = tokio::sync::mpsc::Sender<ZCPacket>; pub type PacketRecvChan = tokio::sync::mpsc::Sender<ZCPacket>;
pub type PacketRecvChanReceiver = tokio::sync::mpsc::Receiver<ZCPacket>; pub type PacketRecvChanReceiver = tokio::sync::mpsc::Receiver<ZCPacket>;
pub fn create_packet_recv_chan() -> (PacketRecvChan, PacketRecvChanReceiver) { pub fn create_packet_recv_chan() -> (PacketRecvChan, PacketRecvChanReceiver) {
hotpath::channel!(tokio::sync::mpsc::channel(128)) tokio::sync::mpsc::channel(128)
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PacketRecvChan"))]
pub async fn recv_packet_from_chan( pub async fn recv_packet_from_chan(
packet_recv_chan_receiver: &mut PacketRecvChanReceiver, packet_recv_chan_receiver: &mut PacketRecvChanReceiver,
) -> Result<ZCPacket, anyhow::Error> { ) -> Result<ZCPacket, anyhow::Error> {
use tokio::sync::mpsc::error::TryRecvError; packet_recv_chan_receiver
match packet_recv_chan_receiver.try_recv() { .recv()
Ok(pkt) => Ok(pkt), .await
Err(TryRecvError::Empty) => packet_recv_chan_receiver .ok_or(anyhow::anyhow!("recv_packet_from_chan failed"))
.recv()
.await
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed")),
Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("recv_packet_from_chan failed")),
}
} }
pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_"; pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_";
-7
View File
@@ -207,7 +207,6 @@ impl Peer {
.map(|conn| conn.clone()) .map(|conn| conn.clone())
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "Peer"))]
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> { pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
let Some(conn) = self.select_conn().await else { let Some(conn) = self.select_conn().await else {
return Err(Error::PeerNoConnectionError(self.peer_node_id)); return Err(Error::PeerNoConnectionError(self.peer_node_id));
@@ -268,12 +267,6 @@ impl Peer {
self.default_conn_id.load() self.default_conn_id.load()
} }
pub fn set_batch_threshold(&self, n: u32) {
for conn in self.conns.iter() {
conn.value().set_batch_threshold(n);
}
}
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> { pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
self.peer_identity_type.load() self.peer_identity_type.load()
} }
+57 -38
View File
@@ -1,3 +1,4 @@
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use futures::{StreamExt, TryFutureExt}; use futures::{StreamExt, TryFutureExt};
use std::{ use std::{
@@ -10,13 +11,6 @@ use std::{
}, },
}; };
#[cfg(feature = "hotpath")]
use hotpath::wrap::std::sync::Mutex as StdMutex;
#[cfg(feature = "hotpath")]
use hotpath::wrap::tokio::sync::Mutex;
#[cfg(not(feature = "hotpath"))]
use std::sync::Mutex as StdMutex;
#[cfg(not(feature = "hotpath"))]
use tokio::sync::Mutex; use tokio::sync::Mutex;
use base64::Engine as _; use base64::Engine as _;
@@ -107,7 +101,7 @@ struct PeerSessionTunnelFilter {
enabled: bool, enabled: bool,
my_peer_id: Arc<AtomicCell<PeerId>>, my_peer_id: Arc<AtomicCell<PeerId>>,
peer_id: Arc<AtomicCell<Option<PeerId>>>, peer_id: Arc<AtomicCell<Option<PeerId>>>,
session: Arc<StdMutex<Option<Arc<PeerSession>>>>, session: Arc<ArcSwapOption<PeerSession>>,
} }
impl PeerSessionTunnelFilter { impl PeerSessionTunnelFilter {
@@ -116,7 +110,7 @@ impl PeerSessionTunnelFilter {
enabled, enabled,
my_peer_id: Arc::new(AtomicCell::new(PeerId::default())), my_peer_id: Arc::new(AtomicCell::new(PeerId::default())),
peer_id: Arc::new(AtomicCell::new(None)), peer_id: Arc::new(AtomicCell::new(None)),
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))), session: Arc::new(ArcSwapOption::empty()),
} }
} }
@@ -125,7 +119,7 @@ impl PeerSessionTunnelFilter {
enabled, enabled,
my_peer_id: Arc::new(AtomicCell::new(my_peer_id)), my_peer_id: Arc::new(AtomicCell::new(my_peer_id)),
peer_id: Arc::new(AtomicCell::new(None)), peer_id: Arc::new(AtomicCell::new(None)),
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))), session: Arc::new(ArcSwapOption::empty()),
} }
} }
@@ -138,7 +132,7 @@ impl PeerSessionTunnelFilter {
} }
fn set_session(&self, session: Arc<PeerSession>) { fn set_session(&self, session: Arc<PeerSession>) {
*self.session.lock().unwrap() = Some(session); self.session.store(Some(session));
} }
fn should_skip_encrypt(&self, hdr: &crate::tunnel::packet_def::PeerManagerHeader) -> bool { fn should_skip_encrypt(&self, hdr: &crate::tunnel::packet_def::PeerManagerHeader) -> bool {
@@ -172,16 +166,15 @@ impl TunnelFilter for PeerSessionTunnelFilter {
return Some(data); return Some(data);
}; };
let mut guard = self.session.lock().unwrap();
let Some(session) = guard.as_mut() else {
return Some(data);
};
let my_peer_id = self.my_peer_id.load(); let my_peer_id = self.my_peer_id.load();
if my_peer_id != hdr.from_peer_id.get() { if my_peer_id != hdr.from_peer_id.get() || hdr.to_peer_id.get() != peer_id {
return Some(data); return Some(data);
} }
let session_guard = self.session.load();
let Some(session) = session_guard.as_deref() else {
return Some(data);
};
if let Err(e) = session.encrypt_payload(my_peer_id, peer_id, &mut data) { if let Err(e) = session.encrypt_payload(my_peer_id, peer_id, &mut data) {
tracing::warn!( tracing::warn!(
?my_peer_id, ?my_peer_id,
@@ -226,8 +219,8 @@ impl TunnelFilter for PeerSessionTunnelFilter {
return Some(Ok(data)); return Some(Ok(data));
} }
let mut guard = self.session.lock().unwrap(); let session_guard = self.session.load();
let Some(session) = guard.as_mut() else { let Some(session) = session_guard.as_deref() else {
return Some(Ok(data)); return Some(Ok(data));
}; };
@@ -370,15 +363,7 @@ impl PeerConn {
let throughput = peer_conn_tunnel_filter.filter_output(); let throughput = peer_conn_tunnel_filter.filter_output();
let filter_chain = TunnelFilterChain::new(session_filter.clone(), peer_conn_tunnel_filter); let filter_chain = TunnelFilterChain::new(session_filter.clone(), peer_conn_tunnel_filter);
let peer_conn_tunnel = TunnelWithFilter::new(tunnel, filter_chain); let peer_conn_tunnel = TunnelWithFilter::new(tunnel, filter_chain);
let supports_direct = peer_conn_tunnel let mut mpsc_tunnel = MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)));
.info()
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp" | "tcp"))
.unwrap_or(false);
let mut mpsc_tunnel = if supports_direct {
MpscTunnel::new_direct(peer_conn_tunnel)
} else {
MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)))
};
let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink()); let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink());
@@ -396,12 +381,12 @@ impl PeerConn {
session_filter, session_filter,
noise_handshake_result: None, noise_handshake_result: None,
tunnel: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Box::new( tunnel: Arc::new(Mutex::new(
guard!([mut mpsc_tunnel] mpsc_tunnel.close()), Box::new(guard!([mut mpsc_tunnel] mpsc_tunnel.close()))
) as Box<dyn Any + Send + 'static>,
as Box<dyn Any + Send + 'static>))), )),
sink, sink,
recv: hotpath::mutex!(tokio::sync::Mutex::new(Some(recv))), recv: Mutex::new(Some(recv)),
tunnel_info, tunnel_info,
tasks: JoinSet::new(), tasks: JoinSet::new(),
@@ -451,10 +436,6 @@ impl PeerConn {
self.conn_id self.conn_id
} }
pub fn set_batch_threshold(&self, n: u32) {
self.sink.set_batch_threshold(n);
}
pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) { pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) {
self.is_hole_punched = is_hole_punched; self.is_hole_punched = is_hole_punched;
} }
@@ -1482,7 +1463,6 @@ impl PeerConn {
}); });
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerConn"))]
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> { pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
Ok(self.sink.send(msg).await?) Ok(self.sink.send(msg).await?)
} }
@@ -1665,6 +1645,45 @@ pub mod tests {
.unwrap_or(0) .unwrap_or(0)
} }
#[test]
fn peer_session_filter_skips_relay_packet_for_next_hop() {
let my_peer_id = 10;
let next_hop_peer_id = 20;
let dst_peer_id = 30;
let filter = PeerSessionTunnelFilter::new_with_peer(my_peer_id, true);
filter.set_peer_id(next_hop_peer_id);
let session = Arc::new(PeerSession::new(
next_hop_peer_id,
PeerSession::new_root_key(),
1,
0,
"aes-gcm".to_string(),
"aes-gcm".to_string(),
None,
));
session.invalidate();
filter.set_session(session);
let mut packet = ZCPacket::new_with_payload(b"relay payload");
packet.fill_peer_manager_hdr(my_peer_id, dst_peer_id, PacketType::Data as u8);
packet
.mut_peer_manager_header()
.unwrap()
.set_encrypted(true);
let original_len = packet.buf_len();
let packet = filter
.before_send(packet)
.expect("relay packet should bypass next-hop session");
let hdr = packet.peer_manager_header().unwrap();
assert_eq!(hdr.from_peer_id.get(), my_peer_id);
assert_eq!(hdr.to_peer_id.get(), dst_peer_id);
assert!(hdr.is_encrypted());
assert_eq!(packet.buf_len(), original_len);
}
#[tokio::test] #[tokio::test]
async fn peer_conn_handshake_same_id() { async fn peer_conn_handshake_same_id() {
let ps = Arc::new(PeerSessionStore::new()); let ps = Arc::new(PeerSessionStore::new());
+1 -1
View File
@@ -6,7 +6,7 @@ use std::{
time::Duration, time::Duration,
}; };
use hotpath::instant::Instant; use quanta::Instant;
use rand::{Rng, thread_rng}; use rand::{Rng, thread_rng};
use tokio::{ use tokio::{
sync::broadcast, sync::broadcast,
+16 -54
View File
@@ -2,7 +2,7 @@ use anyhow::Context;
use async_trait::async_trait; use async_trait::async_trait;
use cidr::{Ipv4Cidr, Ipv6Cidr}; use cidr::{Ipv4Cidr, Ipv6Cidr};
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant; use quanta::Instant;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::{ use std::{
fmt::Debug, fmt::Debug,
@@ -11,9 +11,6 @@ use std::{
time::{Duration, SystemTime}, time::{Duration, SystemTime},
}; };
#[cfg(feature = "hotpath")]
use hotpath::wrap::tokio::sync::{Mutex, RwLock};
#[cfg(not(feature = "hotpath"))]
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tokio::{ use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
@@ -279,8 +276,8 @@ impl PeerManager {
let rpc_tspt = Arc::new(RpcTransport { let rpc_tspt = Arc::new(RpcTransport {
my_peer_id, my_peer_id,
peers: Arc::downgrade(&peers), peers: Arc::downgrade(&peers),
foreign_peers: hotpath::mutex!(tokio::sync::Mutex::new(None)), foreign_peers: Mutex::new(None),
packet_recv: hotpath::mutex!(tokio::sync::Mutex::new(peer_rpc_tspt_recv)), packet_recv: Mutex::new(peer_rpc_tspt_recv),
peer_rpc_tspt_sender, peer_rpc_tspt_sender,
encryptor: encryptor.clone(), encryptor: encryptor.clone(),
is_secure_mode_enabled, is_secure_mode_enabled,
@@ -412,21 +409,17 @@ impl PeerManager {
global_ctx, global_ctx,
nic_channel, nic_channel,
tasks: hotpath::mutex!(tokio::sync::Mutex::new(JoinSet::new())), tasks: Mutex::new(JoinSet::new()),
packet_recv: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Some(packet_recv)))), packet_recv: Arc::new(Mutex::new(Some(packet_recv))),
peers, peers,
peer_rpc_mgr, peer_rpc_mgr,
peer_rpc_tspt: rpc_tspt, peer_rpc_tspt: rpc_tspt,
peer_packet_process_pipeline: Arc::new(hotpath::rw_lock!(tokio::sync::RwLock::new( peer_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
Vec::new() nic_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
))),
nic_packet_process_pipeline: Arc::new(hotpath::rw_lock!(tokio::sync::RwLock::new(
Vec::new()
))),
route_algo_inst, route_algo_inst,
@@ -437,7 +430,7 @@ impl PeerManager {
encryptor, encryptor,
data_compress_algo, data_compress_algo,
exit_nodes: hotpath::rw_lock!(tokio::sync::RwLock::new(exit_nodes)), exit_nodes: RwLock::new(exit_nodes),
reserved_my_peer_id_map: DashMap::new(), reserved_my_peer_id_map: DashMap::new(),
recent_have_traffic: Arc::new(DashMap::new()), recent_have_traffic: Arc::new(DashMap::new()),
@@ -1015,17 +1008,7 @@ impl PeerManager {
self.tasks.lock().await.spawn(async move { self.tasks.lock().await.spawn(async move {
tracing::trace!("start_peer_recv"); tracing::trace!("start_peer_recv");
loop { while let Ok(ret) = recv_packet_from_chan(&mut recv).await {
let ret = match recv.try_recv() {
Ok(pkt) => pkt,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
match recv.recv().await {
Some(pkt) => pkt,
None => break,
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
};
let disable_relay_data = global_ctx.flags_arc().disable_relay_data; let disable_relay_data = global_ctx.flags_arc().disable_relay_data;
let Err(mut ret) = Self::try_handle_foreign_network_packet( let Err(mut ret) = Self::try_handle_foreign_network_packet(
ret, ret,
@@ -1163,11 +1146,9 @@ impl PeerManager {
self_rx_bytes.add(buf_len as u64); self_rx_bytes.add(buf_len as u64);
self_rx_packets.inc(); self_rx_packets.inc();
if !traffic_metrics.record_rx_fast(from_peer_id, packet_type, buf_len as u64) { traffic_metrics
traffic_metrics .record_rx(from_peer_id, packet_type, buf_len as u64)
.record_rx(from_peer_id, packet_type, buf_len as u64) .await;
.await;
}
compress_rx_bytes_before.add(buf_len as u64); compress_rx_bytes_before.add(buf_len as u64);
let compressor = DefaultCompressor {}; let compressor = DefaultCompressor {};
@@ -1456,7 +1437,6 @@ impl PeerManager {
self.get_route().get_foreign_network_summary().await self.get_route().get_foreign_network_summary().await
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
async fn run_nic_packet_process_pipeline(&self, data: &mut ZCPacket) -> bool { async fn run_nic_packet_process_pipeline(&self, data: &mut ZCPacket) -> bool {
// Enforce ACL for outbound (NIC-originated) packets. If ACL denies, stop processing. // Enforce ACL for outbound (NIC-originated) packets. If ACL denies, stop processing.
if !self.global_ctx.get_acl_filter().process_packet_with_acl( if !self.global_ctx.get_acl_filter().process_packet_with_acl(
@@ -1542,7 +1522,6 @@ impl PeerManager {
result result
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
async fn send_msg_internal( async fn send_msg_internal(
peers: &Arc<PeerMap>, peers: &Arc<PeerMap>,
foreign_network_client: &Arc<ForeignNetworkClient>, foreign_network_client: &Arc<ForeignNetworkClient>,
@@ -1568,8 +1547,8 @@ impl PeerManager {
&& (peers.has_peer(gateway) || foreign_network_client.has_next_hop(gateway)) && (peers.has_peer(gateway) || foreign_network_client.has_next_hop(gateway))
{ {
relay_peer_map.send_msg(msg, dst_peer_id, policy).await relay_peer_map.send_msg(msg, dst_peer_id, policy).await
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) { } else if peers.has_peer(dst_peer_id) {
peer.send_msg(msg).await peers.send_msg_directly(msg, dst_peer_id).await
} else if foreign_network_client.has_next_hop(dst_peer_id) { } else if foreign_network_client.has_next_hop(dst_peer_id) {
foreign_network_client.send_msg(msg, dst_peer_id).await foreign_network_client.send_msg(msg, dst_peer_id).await
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await { } else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
@@ -1594,9 +1573,7 @@ impl PeerManager {
if send_result.is_ok() if send_result.is_ok()
&& let Some(metrics) = direct_tx_metrics && let Some(metrics) = direct_tx_metrics
{ {
if !metrics.record_tx_fast(dst_peer_id, packet_type, msg_len) { metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
}
} }
send_result send_result
@@ -1643,7 +1620,6 @@ impl PeerManager {
.collect() .collect()
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec<PeerId>, bool) { pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec<PeerId>, bool) {
let mut is_exit_node = false; let mut is_exit_node = false;
let mut dst_peers = vec![]; let mut dst_peers = vec![];
@@ -1712,7 +1688,6 @@ impl PeerManager {
(dst_peers, is_exit_node) (dst_peers, is_exit_node)
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn try_compress_and_encrypt( pub async fn try_compress_and_encrypt(
compress_algo: CompressorAlgo, compress_algo: CompressorAlgo,
encryptor: &Arc<dyn Encryptor + 'static>, encryptor: &Arc<dyn Encryptor + 'static>,
@@ -1730,7 +1705,6 @@ impl PeerManager {
Ok(()) Ok(())
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn send_msg_by_ip( pub async fn send_msg_by_ip(
&self, &self,
mut msg: ZCPacket, mut msg: ZCPacket,
@@ -1994,18 +1968,6 @@ impl PeerManager {
self.peers.clone() self.peers.clone()
} }
pub fn set_peer_conn_batch_threshold(&self, n: u32) {
let peers = self.peers.clone();
tokio::spawn(async move {
let peer_ids = peers.list_peers();
for peer_id in peer_ids {
if let Some(peer) = peers.get_peer_by_id(peer_id) {
peer.set_batch_threshold(n);
}
}
});
}
pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> { pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> {
self.relay_peer_map.clone() self.relay_peer_map.clone()
} }
@@ -2236,7 +2198,7 @@ mod tests {
use base64::Engine; use base64::Engine;
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration}; use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
use hotpath::instant::Instant; use quanta::Instant;
use crate::{ use crate::{
common::{ common::{
-1
View File
@@ -38,7 +38,6 @@ pub struct PeerMap {
alive_client_urls: Arc<Mutex<multimap::MultiMap<url::Url, PeerConnId>>>, alive_client_urls: Arc<Mutex<multimap::MultiMap<url::Url, PeerConnId>>>,
} }
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl PeerMap { impl PeerMap {
pub fn new(packet_send: PacketRecvChan, global_ctx: ArcGlobalCtx, my_peer_id: PeerId) -> Self { pub fn new(packet_send: PacketRecvChan, global_ctx: ArcGlobalCtx, my_peer_id: PeerId) -> Self {
PeerMap { PeerMap {
+1 -3
View File
@@ -13,7 +13,6 @@ use arc_swap::ArcSwap;
use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet}; use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet};
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use ordered_hash_map::OrderedHashMap; use ordered_hash_map::OrderedHashMap;
use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard}; use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard};
use petgraph::{ use petgraph::{
@@ -25,6 +24,7 @@ use petgraph::{
use prefix_trie::PrefixMap; use prefix_trie::PrefixMap;
use prost::Message; use prost::Message;
use prost_reflect::{DynamicMessage, ReflectMessage}; use prost_reflect::{DynamicMessage, ReflectMessage};
use quanta::Instant;
use tokio::{ use tokio::{
select, select,
sync::Mutex, sync::Mutex,
@@ -1394,7 +1394,6 @@ impl RouteTable {
} }
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "RouteTable"))]
fn get_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> { fn get_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> {
if self.suppressed_peer_ids.contains_key(&dst_peer_id) { if self.suppressed_peer_ids.contains_key(&dst_peer_id) {
return None; return None;
@@ -1402,7 +1401,6 @@ impl RouteTable {
self.get_topology_next_hop(dst_peer_id) self.get_topology_next_hop(dst_peer_id)
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "RouteTable"))]
fn get_topology_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> { fn get_topology_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> {
let cur_version = self.next_hop_map_version.get(); let cur_version = self.next_hop_map_version.get();
self.next_hop_map.get(&dst_peer_id).and_then(|x| { self.next_hop_map.get(&dst_peer_id).and_then(|x| {
+167 -15
View File
@@ -1,7 +1,7 @@
use std::net::SocketAddr; use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use crate::{ use crate::{
common::global_ctx::ArcGlobalCtx, common::{global_ctx::ArcGlobalCtx, network::IPCollector},
proto::{ proto::{
common::Void, common::Void,
peer_rpc::{ peer_rpc::{
@@ -12,6 +12,8 @@ use crate::{
tunnel::udp, tunnel::udp,
}; };
const MAX_UDP_HOLE_PUNCH_CONNECTOR_ADDRS: usize = 16;
fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGlobalCtx) { fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGlobalCtx) {
ret.interface_ipv6s.retain(|ip| { ret.interface_ipv6s.retain(|ip| {
let ip = std::net::Ipv6Addr::from(*ip); let ip = std::net::Ipv6Addr::from(*ip);
@@ -28,6 +30,86 @@ fn remove_easytier_managed_ipv6s(ret: &mut GetIpListResponse, global_ctx: &ArcGl
} }
} }
fn is_usable_preferred_src_ipv6(ip: &Ipv6Addr, global_ctx: &ArcGlobalCtx) -> bool {
!global_ctx.is_ip_easytier_managed_ipv6(ip)
&& !ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_unique_local()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()
}
async fn local_preferred_src_ipv6(
global_ctx: &ArcGlobalCtx,
preferred_src_ipv6: Option<crate::proto::common::Ipv6Addr>,
) -> Option<udp::PreferredIpv6Source> {
let preferred_src_ipv6 = preferred_src_ipv6.map(Ipv6Addr::from)?;
if !is_usable_preferred_src_ipv6(&preferred_src_ipv6, global_ctx) {
tracing::debug!(
?preferred_src_ipv6,
"ignore unusable preferred IPv6 source for udp hole punch"
);
return None;
}
let ifaces = IPCollector::collect_interfaces(global_ctx.net_ns.clone(), false).await;
for iface in ifaces {
let is_local = iface.ips.iter().any(|ip| match ip.ip() {
IpAddr::V6(v6) => v6 == preferred_src_ipv6,
IpAddr::V4(_) => false,
});
if is_local {
tracing::debug!(
?preferred_src_ipv6,
ifindex = iface.index,
"use preferred IPv6 source for udp hole punch"
);
return Some(udp::PreferredIpv6Source {
ip: preferred_src_ipv6,
ifindex: iface.index,
});
}
}
tracing::debug!(
?preferred_src_ipv6,
"ignore non-local preferred IPv6 source for udp hole punch"
);
None
}
fn connector_addrs_from_request(
req: SendUdpHolePunchPacketRequest,
) -> rpc_types::error::Result<(u16, Vec<SocketAddr>, Option<crate::proto::common::Ipv6Addr>)> {
let listener_port = u16::try_from(req.listener_port)
.map_err(|_| anyhow::anyhow!("listener_port is out of range: {}", req.listener_port))?;
let mut connector_addrs = req
.connector_addrs
.into_iter()
.map(SocketAddr::from)
.collect::<Vec<_>>();
if connector_addrs.is_empty() {
connector_addrs.push(
req.connector_addr
.ok_or(anyhow::anyhow!("connector_addr is required"))?
.into(),
);
}
let mut deduped = Vec::with_capacity(connector_addrs.len());
for addr in connector_addrs {
if !deduped.contains(&addr) {
deduped.push(addr);
}
if deduped.len() >= MAX_UDP_HOLE_PUNCH_CONNECTOR_ADDRS {
break;
}
}
Ok((listener_port, deduped, req.preferred_src_ipv6))
}
#[derive(Clone)] #[derive(Clone)]
pub struct DirectConnectorManagerRpcServer { pub struct DirectConnectorManagerRpcServer {
// TODO: this only cache for one src peer, should make it global // TODO: this only cache for one src peer, should make it global
@@ -67,23 +149,38 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer {
_: BaseController, _: BaseController,
req: SendUdpHolePunchPacketRequest, req: SendUdpHolePunchPacketRequest,
) -> rpc_types::error::Result<Void> { ) -> rpc_types::error::Result<Void> {
let listener_port = req.listener_port as u16; let (listener_port, connector_addrs, preferred_src_ipv6) =
let connector_addr: SocketAddr = req connector_addrs_from_request(req)?;
.connector_addr let preferred_src_ipv6 =
.ok_or(anyhow::anyhow!("connector_addr is required"))? local_preferred_src_ipv6(&self.global_ctx, preferred_src_ipv6).await;
.into();
tracing::info!( tracing::info!(
"Sending udp hole punch packet to {} from listener port {}", ?connector_addrs,
connector_addr, ?preferred_src_ipv6,
listener_port listener_port,
"Sending udp hole punch packet"
); );
// send 3 packets to the connector // send 3 packets to the connector
for _ in 0..3 { for _ in 0..3 {
match connector_addr { for connector_addr in &connector_addrs {
SocketAddr::V4(addr) => udp::send_v4_hole_punch_packet(listener_port, addr).await?, let ret = match connector_addr {
SocketAddr::V6(addr) => udp::send_v6_hole_punch_packet(listener_port, addr).await?, SocketAddr::V4(addr) => {
udp::send_v4_hole_punch_packet(listener_port, *addr).await
}
SocketAddr::V6(addr) => {
udp::send_v6_hole_punch_packet(listener_port, *addr, preferred_src_ipv6)
.await
}
};
if let Err(e) = ret {
tracing::debug!(
?e,
?connector_addr,
listener_port,
"send udp hole punch packet failed"
);
}
} }
tokio::time::sleep(std::time::Duration::from_millis(30)).await; tokio::time::sleep(std::time::Duration::from_millis(30)).await;
} }
@@ -99,11 +196,12 @@ impl DirectConnectorManagerRpcServer {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeSet; use std::{collections::BTreeSet, net::SocketAddr};
use crate::{ use crate::{
common::global_ctx::tests::get_mock_global_ctx, common::global_ctx::tests::get_mock_global_ctx,
peers::peer_rpc_service::remove_easytier_managed_ipv6s, proto::peer_rpc::GetIpListResponse, peers::peer_rpc_service::{connector_addrs_from_request, remove_easytier_managed_ipv6s},
proto::peer_rpc::{GetIpListResponse, SendUdpHolePunchPacketRequest},
}; };
#[tokio::test] #[tokio::test]
@@ -133,4 +231,58 @@ mod tests {
assert_eq!(ip_list.public_ipv6, None); assert_eq!(ip_list.public_ipv6, None);
assert_eq!(ip_list.interface_ipv6s, vec![physical_ipv6.into()]); assert_eq!(ip_list.interface_ipv6s, vec![physical_ipv6.into()]);
} }
#[test]
fn hole_punch_request_prefers_batch_connector_addrs() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let first_batch_addr: SocketAddr = "[2001:db8::2]:10002".parse().unwrap();
let second_batch_addr: SocketAddr = "[2001:db8::3]:10003".parse().unwrap();
let preferred_src_ipv6: std::net::Ipv6Addr = "2001:db8::4".parse().unwrap();
let (listener_port, connector_addrs, preferred_src) =
connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: 11010,
preferred_src_ipv6: Some(preferred_src_ipv6.into()),
connector_addrs: vec![
first_batch_addr.into(),
first_batch_addr.into(),
second_batch_addr.into(),
],
})
.unwrap();
assert_eq!(listener_port, 11010);
assert_eq!(connector_addrs, vec![first_batch_addr, second_batch_addr]);
assert_eq!(preferred_src, Some(preferred_src_ipv6.into()));
}
#[test]
fn hole_punch_request_falls_back_to_legacy_connector_addr() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let (_, connector_addrs, _) = connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: 11010,
preferred_src_ipv6: None,
connector_addrs: vec![],
})
.unwrap();
assert_eq!(connector_addrs, vec![old_addr]);
}
#[test]
fn hole_punch_request_rejects_out_of_range_listener_port() {
let old_addr: SocketAddr = "[2001:db8::1]:10001".parse().unwrap();
let ret = connector_addrs_from_request(SendUdpHolePunchPacketRequest {
connector_addr: Some(old_addr.into()),
listener_port: u16::MAX as u32 + 1,
preferred_src_ipv6: None,
connector_addrs: vec![],
});
assert!(ret.is_err());
}
} }
+129 -19
View File
@@ -2,9 +2,12 @@ use std::sync::{
Arc, RwLock, Arc, RwLock,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
}; };
use std::time::Duration;
use anyhow::anyhow; use anyhow::anyhow;
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap; use dashmap::DashMap;
use quanta::Instant;
use super::secure_datagram::{SecureDatagramDirection, SecureDatagramSession}; use super::secure_datagram::{SecureDatagramDirection, SecureDatagramSession};
use crate::{ use crate::{
@@ -12,6 +15,8 @@ use crate::{
tunnel::packet_def::ZCPacket, tunnel::packet_def::ZCPacket,
}; };
const SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub struct UpsertResponderSessionReturn { pub struct UpsertResponderSessionReturn {
pub session: Arc<PeerSession>, pub session: Arc<PeerSession>,
pub action: PeerSessionAction, pub action: PeerSessionAction,
@@ -44,7 +49,25 @@ impl SessionKey {
#[derive(Clone)] #[derive(Clone)]
pub struct PeerSessionStore { pub struct PeerSessionStore {
sessions: Arc<DashMap<SessionKey, Arc<PeerSession>>>, sessions: Arc<DashMap<SessionKey, PeerSessionEntry>>,
}
struct PeerSessionEntry {
session: Arc<PeerSession>,
last_used_at: AtomicCell<Instant>,
}
impl PeerSessionEntry {
fn new(session: Arc<PeerSession>) -> Self {
Self {
session,
last_used_at: AtomicCell::new(Instant::now()),
}
}
fn touch(&self) {
self.last_used_at.store(Instant::now());
}
} }
impl Default for PeerSessionStore { impl Default for PeerSessionStore {
@@ -61,7 +84,11 @@ impl PeerSessionStore {
} }
pub fn get(&self, key: &SessionKey) -> Option<Arc<PeerSession>> { pub fn get(&self, key: &SessionKey) -> Option<Arc<PeerSession>> {
let session = self.sessions.get(key)?.clone(); let session = {
let entry = self.sessions.get(key)?;
entry.touch();
entry.session.clone()
};
if session.is_valid() { if session.is_valid() {
Some(session) Some(session)
} else { } else {
@@ -75,12 +102,20 @@ impl PeerSessionStore {
} }
pub fn insert_session(&self, key: SessionKey, session: Arc<PeerSession>) { pub fn insert_session(&self, key: SessionKey, session: Arc<PeerSession>) {
self.sessions.insert(key, session); self.sessions.insert(key, PeerSessionEntry::new(session));
} }
pub fn evict_unused_sessions(&self) { pub fn evict_unused_sessions(&self) {
self.sessions self.evict_unused_sessions_idle(SESSION_IDLE_TIMEOUT);
.retain(|_key, session| Arc::strong_count(session) > 1); }
pub fn evict_unused_sessions_idle(&self, idle: Duration) {
let now = Instant::now();
self.sessions.retain(|_key, entry| {
entry.session.is_valid()
&& (Arc::strong_count(&entry.session) > 1
|| now.saturating_duration_since(entry.last_used_at.load()) < idle)
});
shrink_dashmap(&self.sessions, None); shrink_dashmap(&self.sessions, None);
} }
@@ -93,11 +128,14 @@ impl PeerSessionStore {
recv_algorithm: String, recv_algorithm: String,
peer_static_pubkey: Option<[u8; 32]>, peer_static_pubkey: Option<[u8; 32]>,
) -> Result<UpsertResponderSessionReturn, anyhow::Error> { ) -> Result<UpsertResponderSessionReturn, anyhow::Error> {
tracing::event!(tracing::Level::INFO, "upsert_responder_session {:?}", key); tracing::event!(tracing::Level::INFO, ?key, "upsert_responder_session");
let existing = self let existing = self
.sessions .sessions
.get(key) .get(key)
.map(|v| v.clone()) .map(|v| {
v.touch();
v.session.clone()
})
.filter(|s| s.is_valid()); .filter(|s| s.is_valid());
match existing { match existing {
None => { None => {
@@ -113,7 +151,8 @@ impl PeerSessionStore {
recv_algorithm, recv_algorithm,
peer_static_pubkey, peer_static_pubkey,
)); ));
self.sessions.insert(key.clone(), session.clone()); self.sessions
.insert(key.clone(), PeerSessionEntry::new(session.clone()));
Ok(UpsertResponderSessionReturn { Ok(UpsertResponderSessionReturn {
session, session,
action: PeerSessionAction::Create, action: PeerSessionAction::Create,
@@ -178,16 +217,14 @@ impl PeerSessionStore {
PeerSessionAction::Sync | PeerSessionAction::Create => { PeerSessionAction::Sync | PeerSessionAction::Create => {
let root_key = root_key_32.ok_or_else(|| anyhow!("missing root_key"))?; let root_key = root_key_32.ok_or_else(|| anyhow!("missing root_key"))?;
if let Some(existing) = self.sessions.get(key) if let Some(existing) = self.sessions.get(key)
&& !existing.is_valid() && !existing.session.is_valid()
{ {
drop(existing); drop(existing);
self.sessions.remove(key); self.sessions.remove(key);
} }
let session = self let session = {
.sessions let entry = self.sessions.entry(key.clone()).or_insert_with(|| {
.entry(key.clone()) PeerSessionEntry::new(Arc::new(PeerSession::new(
.or_insert_with(|| {
Arc::new(PeerSession::new(
key.peer_id, key.peer_id,
root_key, root_key,
b_session_generation, b_session_generation,
@@ -195,9 +232,11 @@ impl PeerSessionStore {
send_algorithm.clone(), send_algorithm.clone(),
recv_algorithm.clone(), recv_algorithm.clone(),
peer_static_pubkey, peer_static_pubkey,
)) )))
}) });
.clone(); entry.touch();
entry.session.clone()
};
session.check_encrypt_algo_same(&send_algorithm, &recv_algorithm)?; session.check_encrypt_algo_same(&send_algorithm, &recv_algorithm)?;
session.check_or_set_peer_static_pubkey(peer_static_pubkey)?; session.check_or_set_peer_static_pubkey(peer_static_pubkey)?;
session.sync_root_key( session.sync_root_key(
@@ -337,7 +376,6 @@ impl PeerSession {
} }
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerSession"))]
pub fn encrypt_payload( pub fn encrypt_payload(
&self, &self,
sender_peer_id: PeerId, sender_peer_id: PeerId,
@@ -351,7 +389,6 @@ impl PeerSession {
.encrypt_payload(Self::dir_for_sender(sender_peer_id, receiver_peer_id), pkt) .encrypt_payload(Self::dir_for_sender(sender_peer_id, receiver_peer_id), pkt)
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerSession"))]
pub fn decrypt_payload( pub fn decrypt_payload(
&self, &self,
sender_peer_id: PeerId, sender_peer_id: PeerId,
@@ -421,4 +458,77 @@ mod tests {
SecureDatagramSession::SYNC_RX_GRACE_AFTER_MS SecureDatagramSession::SYNC_RX_GRACE_AFTER_MS
); );
} }
#[test]
fn peer_session_store_keeps_recent_session_without_external_refs() {
let store = PeerSessionStore::new();
let key = SessionKey::new("net".to_string(), 20);
let session = Arc::new(PeerSession::new(
20,
PeerSession::new_root_key(),
1,
0,
"aes-gcm".to_string(),
"aes-gcm".to_string(),
None,
));
store.insert_session(key.clone(), session);
assert!(store.get(&key).is_some());
store.evict_unused_sessions();
assert!(
store.get(&key).is_some(),
"recent relay sessions should survive the periodic GC"
);
}
#[test]
fn peer_session_store_evicts_idle_session_without_external_refs() {
let store = PeerSessionStore::new();
let key = SessionKey::new("net".to_string(), 20);
let session = Arc::new(PeerSession::new(
20,
PeerSession::new_root_key(),
1,
0,
"aes-gcm".to_string(),
"aes-gcm".to_string(),
None,
));
store.insert_session(key.clone(), session);
store.evict_unused_sessions_idle(Duration::from_millis(0));
assert!(
store.get(&key).is_none(),
"idle sessions without external users should still be collected"
);
}
#[test]
fn peer_session_store_evicts_invalid_recent_session() {
let store = PeerSessionStore::new();
let key = SessionKey::new("net".to_string(), 20);
let session = Arc::new(PeerSession::new(
20,
PeerSession::new_root_key(),
1,
0,
"aes-gcm".to_string(),
"aes-gcm".to_string(),
None,
));
store.insert_session(key.clone(), session);
let session = store.get(&key).unwrap();
session.invalidate();
drop(session);
store.evict_unused_sessions();
assert!(
!store.sessions.contains_key(&key),
"invalid sessions should not be kept by recent activity"
);
}
} }
+1 -1
View File
@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use prost::Message; use prost::Message;
use quanta::Instant;
use snow::params::NoiseParams; use snow::params::NoiseParams;
use tokio::sync::{Mutex, OwnedMutexGuard, oneshot}; use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
+1 -1
View File
@@ -1,7 +1,7 @@
use cidr::Ipv6Inet; use cidr::Ipv6Inet;
use cidr::{Ipv4Cidr, Ipv6Cidr}; use cidr::{Ipv4Cidr, Ipv6Cidr};
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant; use quanta::Instant;
use std::{ use std::{
collections::BTreeSet, collections::BTreeSet,
net::{Ipv4Addr, Ipv6Addr}, net::{Ipv4Addr, Ipv6Addr},
-8
View File
@@ -701,10 +701,6 @@ impl SecureDatagramSession {
false false
} }
#[cfg_attr(
feature = "hotpath",
hotpath::measure(impl_type = "SecureDatagramSession")
)]
pub fn encrypt_payload( pub fn encrypt_payload(
&self, &self,
dir: SecureDatagramDirection, dir: SecureDatagramDirection,
@@ -723,10 +719,6 @@ impl SecureDatagramSession {
Ok(()) Ok(())
} }
#[cfg_attr(
feature = "hotpath",
hotpath::measure(impl_type = "SecureDatagramSession")
)]
pub fn decrypt_payload( pub fn decrypt_payload(
&self, &self,
dir: SecureDatagramDirection, dir: SecureDatagramDirection,
+21 -91
View File
@@ -1,4 +1,4 @@
use std::{future::Future, sync::atomic::{AtomicU64, Ordering}, sync::Arc}; use std::{future::Future, sync::Arc};
use dashmap::DashMap; use dashmap::DashMap;
use futures::future::BoxFuture; use futures::future::BoxFuture;
@@ -18,54 +18,16 @@ pub(crate) enum InstanceLabelKind {
From, From,
} }
#[cfg(not(test))]
const TRAFFIC_BATCH_SIZE: u64 = 128;
#[cfg(test)]
const TRAFFIC_BATCH_SIZE: u64 = 1;
#[derive(Clone)] #[derive(Clone)]
struct TrafficCounters { struct TrafficCounters {
bytes: CounterHandle, bytes: CounterHandle,
packets: CounterHandle, packets: CounterHandle,
batch: Arc<TrafficBatch>,
}
struct TrafficBatch {
bytes: AtomicU64,
packets: AtomicU64,
} }
impl TrafficCounters { impl TrafficCounters {
fn new(bytes: CounterHandle, packets: CounterHandle) -> Self {
Self {
bytes,
packets,
batch: Arc::new(TrafficBatch {
bytes: AtomicU64::new(0),
packets: AtomicU64::new(0),
}),
}
}
fn add_sample(&self, bytes: u64) { fn add_sample(&self, bytes: u64) {
let prev = self.batch.packets.fetch_add(1, Ordering::Relaxed); self.bytes.add(bytes);
self.batch.bytes.fetch_add(bytes, Ordering::Relaxed); self.packets.inc();
if (prev + 1) % TRAFFIC_BATCH_SIZE == 0 {
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
self.bytes.add(b);
self.packets.add(TRAFFIC_BATCH_SIZE);
}
}
fn flush(&self) {
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
let p = self.batch.packets.swap(0, Ordering::Relaxed);
if b > 0 {
self.bytes.add(b);
}
if p > 0 {
self.packets.add(p);
}
} }
} }
@@ -98,14 +60,14 @@ impl AggregateTrafficMetrics {
let label_set = let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone())); LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self { Self {
tx: TrafficCounters::new( tx: TrafficCounters {
stats_mgr.get_counter(tx_bytes_metric, label_set.clone()), bytes: stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
stats_mgr.get_counter(tx_packets_metric, label_set.clone()), packets: stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
), },
rx: TrafficCounters::new( rx: TrafficCounters {
stats_mgr.get_counter(rx_bytes_metric, label_set.clone()), bytes: stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
stats_mgr.get_counter(rx_packets_metric, label_set), packets: stats_mgr.get_counter(rx_packets_metric, label_set),
), },
} }
} }
@@ -160,10 +122,10 @@ impl LogicalTrafficMetrics {
let label_set = let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone())); LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self { Self {
total: TrafficCounters::new( total: TrafficCounters {
stats_mgr.get_counter(total_bytes_metric, label_set.clone()), bytes: stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
stats_mgr.get_counter(total_packets_metric, label_set), packets: stats_mgr.get_counter(total_packets_metric, label_set),
), },
stats_mgr, stats_mgr,
network_name, network_name,
instance_bytes_metric, instance_bytes_metric,
@@ -173,22 +135,6 @@ impl LogicalTrafficMetrics {
} }
} }
pub(crate) fn record_fast(&self, peer_id: PeerId, bytes: u64) -> bool {
self.total.add_sample(bytes);
if let Some(entry) = self.per_peer.get(&peer_id)
&& entry.value().is_resolved()
{
let counters = match entry.value() {
CachedPeerTrafficCounters::Resolved(c)
| CachedPeerTrafficCounters::Unknown(c) => c,
};
counters.add_sample(bytes);
true
} else {
false
}
}
pub(crate) async fn record_with_resolver<F, Fut>( pub(crate) async fn record_with_resolver<F, Fut>(
&self, &self,
peer_id: PeerId, peer_id: PeerId,
@@ -268,12 +214,14 @@ impl LogicalTrafficMetrics {
let label_set = LabelSet::new() let label_set = LabelSet::new()
.with_label_type(LabelType::NetworkName(self.network_name.clone())) .with_label_type(LabelType::NetworkName(self.network_name.clone()))
.with_label_type(instance_label); .with_label_type(instance_label);
TrafficCounters::new( TrafficCounters {
self.stats_mgr bytes: self
.stats_mgr
.get_counter(self.instance_bytes_metric, label_set.clone()), .get_counter(self.instance_bytes_metric, label_set.clone()),
self.stats_mgr packets: self
.stats_mgr
.get_counter(self.instance_packets_metric, label_set), .get_counter(self.instance_packets_metric, label_set),
) }
} }
} }
@@ -356,15 +304,6 @@ impl TrafficMetricRecorder {
} }
} }
pub(crate) fn record_tx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
if peer_id == self.my_peer_id {
return true;
}
self.tx_metrics
.select(traffic_kind(packet_type))
.record_fast(peer_id, bytes)
}
pub(crate) async fn record_tx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) { pub(crate) async fn record_tx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
if peer_id == self.my_peer_id { if peer_id == self.my_peer_id {
return; return;
@@ -375,15 +314,6 @@ impl TrafficMetricRecorder {
.await; .await;
} }
pub(crate) fn record_rx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
if peer_id == self.my_peer_id {
return true;
}
self.rx_metrics
.select(traffic_kind(packet_type))
.record_fast(peer_id, bytes)
}
pub(crate) async fn record_rx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) { pub(crate) async fn record_rx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
if peer_id == self.my_peer_id { if peer_id == self.my_peer_id {
return; return;
+6
View File
@@ -102,6 +102,12 @@ message NetworkConfig {
optional bool disable_relay_data = 65; optional bool disable_relay_data = 65;
optional bool enable_udp_broadcast_relay = 66; optional bool enable_udp_broadcast_relay = 66;
optional uint32 socket_mark = 67; optional uint32 socket_mark = 67;
repeated NetworkPeerConfig peers = 68;
}
message NetworkPeerConfig {
string uri = 1;
optional string peer_public_key = 2;
} }
message PortForwardConfig { message PortForwardConfig {
+2
View File
@@ -189,6 +189,8 @@ message GetIpListResponse {
message SendUdpHolePunchPacketRequest { message SendUdpHolePunchPacketRequest {
common.SocketAddr connector_addr = 1; common.SocketAddr connector_addr = 1;
uint32 listener_port = 2; uint32 listener_port = 2;
common.Ipv6Addr preferred_src_ipv6 = 3;
repeated common.SocketAddr connector_addrs = 4;
} }
service DirectConnectorRpc { service DirectConnectorRpc {
+1 -1
View File
@@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex};
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use guarden::defer; use guarden::defer;
use hotpath::instant::Instant;
use prost::Message; use prost::Message;
use quanta::Instant;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio::time::timeout; use tokio::time::timeout;
+1 -1
View File
@@ -1,6 +1,6 @@
use prost::{Message as _, length_delimiter_len}; use prost::{Message as _, length_delimiter_len};
use hotpath::instant::Instant; use quanta::Instant;
use crate::{ use crate::{
common::{PeerId, compressor::DefaultCompressor}, common::{PeerId, compressor::DefaultCompressor},
+1 -1
View File
@@ -5,8 +5,8 @@ use std::{
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use hotpath::instant::Instant;
use prost::Message; use prost::Message;
use quanta::Instant;
use tokio::{task::JoinSet, time::timeout}; use tokio::{task::JoinSet, time::timeout};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
+138 -1
View File
@@ -2121,6 +2121,124 @@ pub async fn port_forward_test(
drop_insts(_insts).await; drop_insts(_insts).await;
} }
#[rstest::rstest]
#[case(false, false)]
#[case(true, false)]
#[case(true, true)]
#[serial_test::serial]
#[tokio::test]
pub async fn port_forward_with_inbound_default_drop_acl_test(
#[case] dhcp: bool,
#[case] enable_quic_proxy: bool,
) {
use crate::proto::acl::*;
let acl = Acl {
acl_v1: Some(AclV1 {
chains: vec![Chain {
name: "drop_unsolicited_inbound".to_string(),
chain_type: ChainType::Inbound as i32,
enabled: true,
default_action: Action::Drop as i32,
..Default::default()
}],
..Default::default()
}),
};
let insts = init_three_node_ex(
"udp",
|cfg| {
if cfg.get_inst_name() == "inst1" {
if dhcp {
cfg.set_ipv4(None);
cfg.set_dhcp(true);
}
cfg.set_acl(Some(acl.clone()));
cfg.set_port_forwards(vec![
PortForwardConfig {
bind_addr: "0.0.0.0:23456".parse().unwrap(),
dst_addr: "10.144.144.3:23456".parse().unwrap(),
proto: "tcp".to_string(),
},
PortForwardConfig {
bind_addr: "0.0.0.0:23457".parse().unwrap(),
dst_addr: "10.1.2.4:23457".parse().unwrap(),
proto: "tcp".to_string(),
},
]);
let mut flags = cfg.get_flags();
flags.no_tun = true;
flags.enable_kcp_proxy = false;
flags.enable_quic_proxy = enable_quic_proxy;
cfg.set_flags(flags);
} else if cfg.get_inst_name() == "inst3" {
cfg.add_proxy_cidr("10.1.2.0/24".parse().unwrap(), None)
.unwrap();
let mut flags = cfg.get_flags();
flags.disable_kcp_input = true;
flags.disable_quic_input = !enable_quic_proxy;
cfg.set_flags(flags);
} else if cfg.get_inst_name() == "inst2" {
let mut flags = cfg.get_flags();
flags.disable_relay_kcp = true;
cfg.set_flags(flags);
}
cfg
},
false,
)
.await;
if dhcp {
wait_for_condition(
|| async { insts[0].get_global_ctx().get_ipv4().is_some() },
Duration::from_secs(5),
)
.await;
}
for (bind_port, server_ns) in [(23456, "net_c"), (23457, "net_d")] {
let tcp_listener =
TcpTunnelListener::new(format!("tcp://0.0.0.0:{bind_port}").parse().unwrap());
let tcp_connector =
TcpTunnelConnector::new(format!("tcp://127.0.0.1:{bind_port}").parse().unwrap());
let mut buf = vec![0; 64];
rand::thread_rng().fill(&mut buf[..]);
let result = _tunnel_pingpong_netns_with_timeout(
tcp_listener,
tcp_connector,
NetNS::new(Some(server_ns.into())),
NetNS::new(Some("net_a".into())),
buf,
Duration::from_secs(1),
)
.await;
let stats = insts[0].get_global_ctx().get_acl_filter().get_stats();
println!(
"port forward source bind_port={} dhcp={} enable_quic_proxy={} ACL stats: {}",
bind_port, dhcp, enable_quic_proxy, stats
);
assert!(
result.is_ok(),
"port-forward TCP should complete through outbound ACL state, bind_port={}, dhcp={}, enable_quic_proxy={}; stats: {}",
bind_port,
dhcp,
enable_quic_proxy,
stats,
);
}
drop_insts(insts).await;
}
#[rstest::rstest] #[rstest::rstest]
#[serial_test::serial] #[serial_test::serial]
#[tokio::test] #[tokio::test]
@@ -3653,6 +3771,25 @@ pub async fn config_patch_test() {
true true
}, },
); );
let patch = InstanceConfigPatch {
proxy_networks: vec![ProxyNetworkPatch {
action: ConfigPatchAction::Clear as i32,
..Default::default()
}],
..Default::default()
};
insts[1]
.get_config_patcher()
.apply_patch(patch)
.await
.unwrap();
assert!(
insts[1]
.get_global_ctx()
.config
.get_proxy_cidrs()
.is_empty()
);
// 测试1.1:修改公网 IPv6 provider 相关配置 // 测试1.1:修改公网 IPv6 provider 相关配置
let public_prefix = "2001:db8:100::/64"; let public_prefix = "2001:db8:100::/64";
@@ -4203,7 +4340,7 @@ pub async fn relay_peer_session_cleanup() {
insts[0] insts[0]
.get_peer_manager() .get_peer_manager()
.get_peer_session_store() .get_peer_session_store()
.evict_unused_sessions(); .evict_unused_sessions_idle(Duration::from_millis(0));
wait_for_condition( wait_for_condition(
|| async { !relay_map_1.has_session(inst3_peer_id) }, || async { !relay_map_1.has_session(inst3_peer_id) },
+1 -1
View File
@@ -159,7 +159,7 @@ impl Socket {
ack: Option<u32>, ack: Option<u32>,
state: State, state: State,
) -> (Socket, flume::Sender<Bytes>) { ) -> (Socket, flume::Sender<Bytes>) {
let (incoming_tx, incoming_rx) = hotpath::channel!(flume::bounded(MPMC_BUFFER_LEN)); let (incoming_tx, incoming_rx) = flume::bounded(MPMC_BUFFER_LEN);
( (
Socket { Socket {
+1
View File
@@ -25,6 +25,7 @@ pub mod ring;
pub mod stats; pub mod stats;
pub mod tcp; pub mod tcp;
pub mod udp; pub mod udp;
pub(crate) mod udp_src;
#[cfg(feature = "faketcp")] #[cfg(feature = "faketcp")]
pub mod fake_tcp; pub mod fake_tcp;
+15 -161
View File
@@ -1,13 +1,6 @@
// this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel // this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel
use std::{ use std::{pin::Pin, time::Duration};
cell::UnsafeCell,
pin::Pin,
sync::Arc,
sync::atomic::{AtomicBool, AtomicU32, Ordering},
task::Poll,
time::Duration,
};
use anyhow::Context; use anyhow::Context;
use tokio::time::timeout; use tokio::time::timeout;
@@ -18,151 +11,39 @@ use super::{Tunnel, TunnelError, ZCPacketSink, ZCPacketStream, packet_def::ZCPac
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError}; use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError};
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
// use tachyonix::{channel, Receiver, Sender, TrySendError};
use futures::SinkExt; use futures::SinkExt;
/// A simple spinlock protecting a sink. The guard is Send because it only
/// contains an atomic flag reference (no lifetime-tied borrow like MutexGuard).
struct SpinSink {
locked: AtomicBool,
sink: UnsafeCell<Pin<Box<dyn ZCPacketSink>>>,
pending_count: AtomicU32,
batch_threshold: AtomicU32,
}
// SAFETY: access is serialized by the spinlock.
unsafe impl Send for SpinSink {}
unsafe impl Sync for SpinSink {}
struct SpinGuard<'a> {
spin: &'a SpinSink,
}
impl<'a> SpinGuard<'a> {
fn as_mut(&mut self) -> Pin<&mut dyn ZCPacketSink> {
// SAFETY: we hold the spinlock, so we have exclusive access
let sink = unsafe { &mut *self.spin.sink.get() };
sink.as_mut()
}
}
impl Drop for SpinGuard<'_> {
fn drop(&mut self) {
self.spin.locked.store(false, Ordering::Release);
}
}
impl SpinSink {
fn new(sink: Pin<Box<dyn ZCPacketSink>>) -> Self {
Self {
locked: AtomicBool::new(false),
sink: UnsafeCell::new(sink),
pending_count: AtomicU32::new(0),
batch_threshold: AtomicU32::new(1),
}
}
fn set_batch_threshold(&self, n: u32) {
self.batch_threshold.store(n, Ordering::Relaxed);
}
fn try_lock(&self) -> Option<SpinGuard<'_>> {
if self
.locked
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
Some(SpinGuard { spin: self })
} else {
None
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct MpscTunnelSender { pub struct MpscTunnelSender(Sender<ZCPacket>);
channel_tx: Option<Sender<ZCPacket>>,
direct_sink: Option<Arc<SpinSink>>,
direct_batch_flush: bool,
}
impl MpscTunnelSender { impl MpscTunnelSender {
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))]
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> { pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink { self.0.send(item).await.with_context(|| "send error")?;
// Sync fast path: no await needed, returns immediately Ok(())
if let Some(mut guard) = sink.try_lock() {
let waker = futures::task::noop_waker();
let mut cx = std::task::Context::from_waker(&waker);
match guard.as_mut().poll_ready(&mut cx) {
Poll::Ready(Ok(())) => {
guard.as_mut().start_send(item)?;
let count = sink.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
let threshold = sink.batch_threshold.load(Ordering::Relaxed);
if count >= threshold {
sink.pending_count.store(0, Ordering::Relaxed);
// Batch flush: writev all accumulated BufList entries.
// RingSink: no-op. FramedWriter: single writev syscall.
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()),
}
}
// Accumulate in BufList, no flush yet
return Ok(());
}
Poll::Ready(Err(e)) => return Err(e),
Poll::Pending => return Err(TunnelError::BufferFull),
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
} }
pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> { pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> {
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?; self.0.try_send(item).map_err(|e| match e {
tx.try_send(item).map_err(|e| match e {
TrySendError::Full(_) => TunnelError::BufferFull, TrySendError::Full(_) => TunnelError::BufferFull,
TrySendError::Closed(_) => TunnelError::Shutdown, TrySendError::Closed(_) => TunnelError::Shutdown,
}) })
} }
pub fn set_batch_threshold(&self, n: u32) {
if let Some(sink) = &self.direct_sink {
sink.set_batch_threshold(n);
}
}
pub async fn send_async(&self, item: ZCPacket) -> Result<(), TunnelError> {
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
match tx.try_send(item) {
Ok(()) => Ok(()),
Err(TrySendError::Full(item)) => {
tx.send(item).await.with_context(|| "send error")?;
Ok(())
}
Err(TrySendError::Closed(_)) => Err(TunnelError::Shutdown),
}
}
} }
pub struct MpscTunnel<T> { pub struct MpscTunnel<T> {
tx: Option<Sender<ZCPacket>>, tx: Option<Sender<ZCPacket>>,
direct_sink: Option<Arc<SpinSink>>,
direct_batch_flush: bool,
tunnel: T, tunnel: T,
stream: Option<Pin<Box<dyn ZCPacketStream>>>, stream: Option<Pin<Box<dyn ZCPacketStream>>>,
task: Option<AbortOnDropHandle<()>>, task: AbortOnDropHandle<()>,
} }
impl<T: Tunnel> MpscTunnel<T> { impl<T: Tunnel> MpscTunnel<T> {
pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self { pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self {
let (tx, mut rx) = hotpath::channel!(channel(1024)); let (tx, mut rx) = channel(32);
let (stream, mut sink) = tunnel.split(); let (stream, mut sink) = tunnel.split();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
@@ -179,32 +60,12 @@ impl<T: Tunnel> MpscTunnel<T> {
Self { Self {
tx: Some(tx), tx: Some(tx),
direct_sink: None,
direct_batch_flush: false,
tunnel, tunnel,
stream: Some(stream), stream: Some(stream),
task: Some(AbortOnDropHandle::new(task)), task: AbortOnDropHandle::new(task),
} }
} }
pub fn new_direct(tunnel: T) -> Self {
let (stream, sink) = tunnel.split();
let info = tunnel.info();
let batch_flush = info
.as_ref()
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp"))
.unwrap_or(false);
Self {
tx: None,
direct_sink: Some(Arc::new(SpinSink::new(sink))),
direct_batch_flush: batch_flush,
tunnel,
stream: Some(stream),
task: None,
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round( async fn forward_one_round(
rx: &mut Receiver<ZCPacket>, rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>, sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -218,7 +79,6 @@ impl<T: Tunnel> MpscTunnel<T> {
} }
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round_no_timeout( async fn forward_one_round_no_timeout(
rx: &mut Receiver<ZCPacket>, rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>, sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -236,7 +96,6 @@ impl<T: Tunnel> MpscTunnel<T> {
sink.flush().await sink.flush().await
} }
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round_with_timeout( async fn forward_one_round_with_timeout(
rx: &mut Receiver<ZCPacket>, rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>, sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -265,19 +124,12 @@ impl<T: Tunnel> MpscTunnel<T> {
} }
pub fn get_sink(&self) -> MpscTunnelSender { pub fn get_sink(&self) -> MpscTunnelSender {
MpscTunnelSender { MpscTunnelSender(self.tx.as_ref().unwrap().clone())
channel_tx: self.tx.as_ref().cloned(),
direct_sink: self.direct_sink.clone(),
direct_batch_flush: self.direct_batch_flush,
}
} }
pub fn close(&mut self) { pub fn close(&mut self) {
self.tx.take(); self.tx.take();
self.direct_sink.take(); self.task.abort();
if let Some(task) = self.task.take() {
task.abort();
}
} }
pub fn tunnel_info(&self) -> Option<TunnelInfo> { pub fn tunnel_info(&self) -> Option<TunnelInfo> {
@@ -337,7 +189,8 @@ mod tests {
for i in 0..1000000 { for i in 0..1000000 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let a = sink1 let a = sink1
.send_async(ZCPacket::new_with_payload("hello".as_bytes())).await; .send(ZCPacket::new_with_payload("hello".as_bytes()))
.await;
if a.is_err() { if a.is_err() {
tracing::info!(?a, "t2 exit with err"); tracing::info!(?a, "t2 exit with err");
break; break;
@@ -356,7 +209,8 @@ mod tests {
for i in 0..1000000 { for i in 0..1000000 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let a = sink2 let a = sink2
.send_async(ZCPacket::new_with_payload("hello2".as_bytes())).await; .send(ZCPacket::new_with_payload("hello2".as_bytes()))
.await;
if a.is_err() { if a.is_err() {
tracing::info!(?a, "t3 exit with err"); tracing::info!(?a, "t3 exit with err");
break; break;
+26 -81
View File
@@ -1,10 +1,9 @@
use bytes::Buf;
use bytes::Bytes; use bytes::Bytes;
use bytes::BytesMut; use bytes::BytesMut;
use zerocopy::byteorder::*;
use zerocopy::AsBytes; use zerocopy::AsBytes;
use zerocopy::FromBytes; use zerocopy::FromBytes;
use zerocopy::FromZeroes; use zerocopy::FromZeroes;
use zerocopy::byteorder::*;
type DefaultEndian = LittleEndian; type DefaultEndian = LittleEndian;
@@ -47,6 +46,8 @@ pub struct V4HolePunchPacket {
pub struct V6HolePunchPacket { pub struct V6HolePunchPacket {
pub dst_ipv6: [u8; 16], pub dst_ipv6: [u8; 16],
pub dst_port: U16<DefaultEndian>, pub dst_port: U16<DefaultEndian>,
pub preferred_src_ipv6: [u8; 16],
pub preferred_src_ifindex: U32<DefaultEndian>,
} }
#[repr(C, packed)] #[repr(C, packed)]
@@ -484,16 +485,8 @@ impl ZCPacket {
let payload_off = ret.packet_type.get_packet_offsets().payload_offset; let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
let total_len = payload_off + payload.len(); let total_len = payload_off + payload.len();
ret.inner.reserve(total_len); ret.inner.reserve(total_len);
unsafe { ret.inner.set_len(total_len) };
// SAFETY: `reserve` guarantees capacity >= total_len. ret.mut_payload()[..payload.len()].copy_from_slice(payload);
// We zero the header region and copy payload before advancing length,
// so every byte in [0..total_len) is initialized before any read.
unsafe {
let ptr = ret.inner.as_mut_ptr();
std::ptr::write_bytes(ptr, 0, payload_off);
std::ptr::copy_nonoverlapping(payload.as_ptr(), ptr.add(payload_off), payload.len());
ret.inner.set_len(total_len);
}
ret ret
} }
@@ -501,12 +494,12 @@ impl ZCPacket {
let mut ret = Self::new_nic_packet(); let mut ret = Self::new_nic_packet();
ret.inner.reserve(cap); ret.inner.reserve(cap);
let total_len = ret.packet_type.get_packet_offsets().payload_offset - packet_info_len; let total_len = ret.packet_type.get_packet_offsets().payload_offset - packet_info_len;
ret.inner.resize(total_len, 0); unsafe { ret.inner.set_len(total_len) };
ret ret
} }
pub fn new_for_foreign_network( pub fn new_for_foreign_network(
network_name: &str, network_name: &String,
dst_peer_id: u32, dst_peer_id: u32,
foreign_zc_packet: &ZCPacket, foreign_zc_packet: &ZCPacket,
) -> Self { ) -> Self {
@@ -515,71 +508,26 @@ impl ZCPacket {
foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len(); foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len();
let mut ret = Self::new_nic_packet(); let mut ret = Self::new_nic_packet();
let offsets = ret.packet_type.get_packet_offsets(); let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
let payload_off = offsets.payload_offset; ret.inner.reserve(payload_off + total_payload_len);
let pm_hdr_off = offsets.peer_manager_header_offset; unsafe { ret.inner.set_len(payload_off + total_payload_len) };
let total_len = payload_off + total_payload_len;
ret.inner.reserve(total_len);
let fixed_hdr_len = std::mem::size_of::<ForeignNetworkPacketHeader>(); let fixed_hdr_len = std::mem::size_of::<ForeignNetworkPacketHeader>();
ret.mut_payload()[..fixed_hdr_len].copy_from_slice(foreign_network_hdr.as_bytes());
let name_offset = foreign_network_hdr.network_name_offset.get() as usize; let name_offset = foreign_network_hdr.network_name_offset.get() as usize;
let name_len = foreign_network_hdr.network_name_len.get() as usize; let name_len = foreign_network_hdr.network_name_len.get() as usize;
let foreign_payload = foreign_zc_packet.tunnel_payload(); ret.mut_payload()[name_offset..name_offset + name_len]
.copy_from_slice(network_name.as_bytes());
// Construct the PeerManagerHeader on the stack so we can write it ret.mut_payload()[foreign_network_hdr.get_header_len()..]
// directly into the buffer, avoiding a separate mut_peer_manager_header() .copy_from_slice(foreign_zc_packet.tunnel_payload());
// call after set_len.
let pm_hdr = PeerManagerHeader {
from_peer_id: 0.into(),
to_peer_id: 0.into(),
packet_type: PacketType::ForeignNetworkPacket as u8,
flags: 0,
forward_counter: 0,
reserved: 0,
len: U32::new(total_payload_len as u32),
};
// SAFETY: `reserve` guarantees capacity >= total_len. let hdr = ret.mut_peer_manager_header().unwrap();
// We zero only the tunnel-header reserved space [0..pm_hdr_off], write hdr.from_peer_id = 0.into();
// the PeerManagerHeader directly at pm_hdr_off, then copy the foreign hdr.to_peer_id = 0.into();
// network header, network name, and payload. Every byte in [0..total_len) hdr.packet_type = PacketType::ForeignNetworkPacket as u8;
// is initialized before set_len. hdr.len.set(total_payload_len as u32);
unsafe {
let ptr = ret.inner.as_mut_ptr();
// Zero the tunnel header reserved space only (not the PM header region)
std::ptr::write_bytes(ptr, 0, pm_hdr_off);
// Write PeerManagerHeader directly
std::ptr::copy_nonoverlapping(
pm_hdr.as_bytes().as_ptr(),
ptr.add(pm_hdr_off),
std::mem::size_of::<PeerManagerHeader>(),
);
// Copy foreign network fixed header
std::ptr::copy_nonoverlapping(
foreign_network_hdr.as_bytes().as_ptr(),
ptr.add(payload_off),
fixed_hdr_len,
);
// Copy network name
std::ptr::copy_nonoverlapping(
network_name.as_ptr(),
ptr.add(payload_off + name_offset),
name_len,
);
// Copy foreign payload
std::ptr::copy_nonoverlapping(
foreign_payload.as_ptr(),
ptr.add(payload_off + foreign_network_hdr.get_header_len()),
foreign_payload.len(),
);
ret.inner.set_len(total_len);
}
ret ret
} }
@@ -639,8 +587,7 @@ impl ZCPacket {
} }
pub fn payload_bytes(mut self) -> BytesMut { pub fn payload_bytes(mut self) -> BytesMut {
self.inner.advance(self.payload_offset()); self.inner.split_off(self.payload_offset())
self.inner
} }
pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> { pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> {
@@ -705,12 +652,11 @@ impl ZCPacket {
} }
pub fn tunnel_payload_bytes(mut self) -> BytesMut { pub fn tunnel_payload_bytes(mut self) -> BytesMut {
self.inner.advance( self.inner.split_off(
self.packet_type self.packet_type
.get_packet_offsets() .get_packet_offsets()
.peer_manager_header_offset, .peer_manager_header_offset,
); )
self.inner
} }
pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self { pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self {
@@ -751,13 +697,12 @@ impl ZCPacket {
.get_packet_offsets() .get_packet_offsets()
.peer_manager_header_offset; .peer_manager_header_offset;
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len()); let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
buf.resize(new_pm_offset, 0); unsafe { buf.set_len(new_pm_offset) };
buf.extend_from_slice(tunnel_payload); buf.extend_from_slice(tunnel_payload);
return Self::new_from_buf(buf, target_packet_type); return Self::new_from_buf(buf, target_packet_type);
} }
self.inner.advance(new_offset); Self::new_from_buf(self.inner.split_off(new_offset), target_packet_type)
Self::new_from_buf(self.inner, target_packet_type)
} }
pub fn into_bytes(self) -> Bytes { pub fn into_bytes(self) -> Bytes {
+2 -4
View File
@@ -11,7 +11,7 @@ use async_trait::async_trait;
use futures::{Sink, SinkExt, Stream, StreamExt}; use futures::{Sink, SinkExt, Stream, StreamExt};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use uuid::Uuid; use uuid::Uuid;
@@ -130,7 +130,6 @@ impl RingSink {
} }
} }
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl Sink<SinkItem> for RingSink { impl Sink<SinkItem> for RingSink {
type Error = SinkError; type Error = SinkError;
@@ -197,8 +196,7 @@ pub struct RingTunnelListener {
impl RingTunnelListener { impl RingTunnelListener {
pub fn new(key: url::Url) -> Self { pub fn new(key: url::Url) -> Self {
let (conn_sender, conn_receiver) = let (conn_sender, conn_receiver) = unbounded_channel();
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
RingTunnelListener { RingTunnelListener {
listener_addr: key, listener_addr: key,
conn_sender, conn_sender,
+276 -176
View File
@@ -2,19 +2,22 @@ use std::{
fmt::Debug, fmt::Debug,
net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
sync::{Arc, Weak}, sync::{Arc, Weak},
time::Duration,
}; };
use anyhow::Context; use anyhow::Context;
use async_trait::async_trait; use async_trait::async_trait;
use bytes::BytesMut; use bytes::BytesMut;
use dashmap::DashMap; use dashmap::DashMap;
use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; use futures::{StreamExt, stream::FuturesUnordered};
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use zerocopy::{AsBytes, FromBytes}; use zerocopy::{AsBytes, FromBytes};
use tokio::{ use tokio::{
net::UdpSocket, net::UdpSocket,
sync::mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, sync::mpsc::{
Receiver, Sender, UnboundedReceiver, UnboundedSender, channel, unbounded_channel,
},
task::JoinSet, task::JoinSet,
}; };
use tokio_util::task::AbortOnDropHandle; use tokio_util::task::AbortOnDropHandle;
@@ -35,6 +38,7 @@ use crate::{
common::{TunnelWrapper, reserve_buf}, common::{TunnelWrapper, reserve_buf},
packet_def::{UdpPacketType, ZCPacket, ZCPacketType}, packet_def::{UdpPacketType, ZCPacket, ZCPacketType},
ring::RingTunnel, ring::RingTunnel,
udp_src,
}, },
}; };
@@ -43,6 +47,12 @@ pub const UDP_DATA_MTU: usize = 2000;
type UdpCloseEventSender = UnboundedSender<(SocketAddr, Option<TunnelError>)>; type UdpCloseEventSender = UnboundedSender<(SocketAddr, Option<TunnelError>)>;
type UdpCloseEventReceiver = UnboundedReceiver<(SocketAddr, Option<TunnelError>)>; type UdpCloseEventReceiver = UnboundedReceiver<(SocketAddr, Option<TunnelError>)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PreferredIpv6Source {
pub ip: Ipv6Addr,
pub ifindex: u32,
}
fn new_udp_packet<F>(f: F, udp_body: Option<&[u8]>) -> ZCPacket fn new_udp_packet<F>(f: F, udp_body: Option<&[u8]>) -> ZCPacket
where where
F: FnOnce(&mut UDPTunnelHeader), F: FnOnce(&mut UDPTunnelHeader),
@@ -97,11 +107,18 @@ pub fn new_hole_punch_packet(tid: u32, buf_len: u16) -> ZCPacket {
) )
} }
pub fn new_v6_hole_punch_packet(dst: &SocketAddrV6) -> ZCPacket { pub fn new_v6_hole_punch_packet(
dst: &SocketAddrV6,
preferred_src: Option<PreferredIpv6Source>,
) -> ZCPacket {
// generate a 128 bytes vec with random data // generate a 128 bytes vec with random data
let mut body = V6HolePunchPacket::default(); let mut body = V6HolePunchPacket::default();
body.dst_ipv6.copy_from_slice(&dst.ip().octets()); body.dst_ipv6.copy_from_slice(&dst.ip().octets());
body.dst_port.set(dst.port()); body.dst_port.set(dst.port());
if let Some(src) = preferred_src {
body.preferred_src_ipv6.copy_from_slice(&src.ip.octets());
body.preferred_src_ifindex.set(src.ifindex);
}
new_udp_packet( new_udp_packet(
|header| { |header| {
header.msg_type = UdpPacketType::V6HolePunch as u8; header.msg_type = UdpPacketType::V6HolePunch as u8;
@@ -136,10 +153,18 @@ fn extract_dst_addr_from_v4_hole_punch_packet(buf: &[u8]) -> Option<SocketAddrV4
Some(SocketAddrV4::new(ip, body.dst_port.get())) Some(SocketAddrV4::new(ip, body.dst_port.get()))
} }
fn extrace_dst_addr_from_hole_punch_packet(buf: &[u8]) -> Option<SocketAddrV6> { fn extract_v6_hole_punch_packet(buf: &[u8]) -> Option<(SocketAddrV6, Option<PreferredIpv6Source>)> {
let body = V6HolePunchPacket::ref_from_prefix(buf)?; let body = V6HolePunchPacket::ref_from_prefix(buf)?;
let ip = Ipv6Addr::from(body.dst_ipv6); let ip = Ipv6Addr::from(body.dst_ipv6);
Some(SocketAddrV6::new(ip, body.dst_port.get(), 0, 0)) let preferred_src_ipv6 = Ipv6Addr::from(body.preferred_src_ipv6);
let preferred_src = (!preferred_src_ipv6.is_unspecified()).then_some(PreferredIpv6Source {
ip: preferred_src_ipv6,
ifindex: body.preferred_src_ifindex.get(),
});
Some((
SocketAddrV6::new(ip, body.dst_port.get(), 0, 0),
preferred_src,
))
} }
fn is_stun_packet(b: &[u8]) -> bool { fn is_stun_packet(b: &[u8]) -> bool {
@@ -152,9 +177,10 @@ fn is_stun_packet(b: &[u8]) -> bool {
pub async fn send_v6_hole_punch_packet( pub async fn send_v6_hole_punch_packet(
listener_port: u16, listener_port: u16,
dst_addr: SocketAddrV6, dst_addr: SocketAddrV6,
preferred_src: Option<PreferredIpv6Source>,
) -> Result<(), TunnelError> { ) -> Result<(), TunnelError> {
let local_socket = UdpSocket::bind("[::1]:0").await?; let local_socket = UdpSocket::bind("[::1]:0").await?;
let udp_packet = new_v6_hole_punch_packet(&dst_addr); let udp_packet = new_v6_hole_punch_packet(&dst_addr, preferred_src);
let remote_addr = format!("[::1]:{}", listener_port) let remote_addr = format!("[::1]:{}", listener_port)
.parse::<SocketAddr>() .parse::<SocketAddr>()
.unwrap(); .unwrap();
@@ -266,151 +292,14 @@ fn get_zcpacket_from_buf(buf: BytesMut, allow_stun: bool) -> Result<ZCPacket, Tu
Ok(zc_packet) Ok(zc_packet)
} }
#[cfg(feature = "udp-gso")] #[instrument]
const UDP_BATCH_SIZE: usize = 8;
#[cfg(feature = "udp-gso")]
async fn forward_from_ring_to_udp( async fn forward_from_ring_to_udp(
mut ring_recv: RingStream, mut ring_recv: RingStream,
socket: &Arc<UdpSocket>, socket: &Arc<UdpSocket>,
addr: &SocketAddr, addr: &SocketAddr,
conn_id: u32, conn_id: u32,
) -> Option<TunnelError> { ) -> Option<TunnelError> {
use quinn_udp::{Transmit, UdpSockRef, UdpSocketState}; tracing::debug!("udp forward from ring to udp");
use std::io;
tracing::debug!("udp forward from ring to udp (GSO batch)");
let udp_state = match UdpSocketState::new(UdpSockRef::from(&**socket)) {
Ok(state) => state,
Err(e) => {
tracing::warn!(?e, "failed to init UdpSocketState, fallback to per-packet send");
return forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await;
}
};
let max_gso = udp_state.max_gso_segments();
tracing::info!(max_gso, "udp GSO segments supported");
loop {
// 1. Get first packet (await)
let first = match ring_recv.next().await {
Some(Ok(pkt)) => convert_to_udp_bytes(pkt, conn_id),
Some(Err(e)) => return Some(e),
None => return None,
};
// 2. Try to drain more packets (non-blocking via poll_next with noop waker)
use std::pin::Pin;
use std::task::{Poll, Context};
use futures::task::noop_waker;
use futures::Stream;
let mut batch: Vec<bytes::Bytes> = vec![first];
while batch.len() < UDP_BATCH_SIZE.min(max_gso) {
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match Pin::new(&mut ring_recv).poll_next(&mut cx) {
Poll::Ready(Some(Ok(pkt))) => {
batch.push(convert_to_udp_bytes(pkt, conn_id));
}
_ => break,
}
}
// 3. Check if all same size (GSO requirement)
let seg_size = batch[0].len();
let all_same = batch.iter().all(|b| b.len() == seg_size);
if batch.len() == 1 || !all_same || max_gso == 1 {
// Fallback: send individually
for buf in &batch {
if let Err(e) = send_one(&udp_state, socket, addr, buf).await {
return Some(TunnelError::IOError(e));
}
}
} else {
// GSO batch: concatenate + single sendmsg
let mut contents = Vec::with_capacity(seg_size * batch.len());
for buf in &batch {
contents.extend_from_slice(buf);
}
let transmit = Transmit {
destination: *addr,
ecn: None,
contents: &contents,
segment_size: Some(seg_size),
src_ip: None,
};
if let Err(e) = send_one_gso(&udp_state, socket, &transmit).await {
return Some(TunnelError::IOError(e));
}
}
}
}
#[cfg(feature = "udp-gso")]
fn convert_to_udp_bytes(mut packet: ZCPacket, conn_id: u32) -> bytes::Bytes {
let mut packet = packet.convert_type(ZCPacketType::UDP);
let udp_payload_len = packet.udp_payload().len();
let header = packet.mut_udp_tunnel_header().unwrap();
header.conn_id.set(conn_id);
header.len.set(udp_payload_len as u16);
header.msg_type = UdpPacketType::Data as u8;
packet.into_bytes()
}
#[cfg(feature = "udp-gso")]
async fn send_one(
udp_state: &quinn_udp::UdpSocketState,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
buf: &[u8],
) -> Result<(), std::io::Error> {
use quinn_udp::{Transmit, UdpSockRef};
let transmit = Transmit {
destination: *addr,
ecn: None,
contents: buf,
segment_size: None,
src_ip: None,
};
send_one_gso(udp_state, socket, &transmit).await
}
#[cfg(feature = "udp-gso")]
async fn send_one_gso(
udp_state: &quinn_udp::UdpSocketState,
socket: &Arc<UdpSocket>,
transmit: &quinn_udp::Transmit<'_>,
) -> Result<(), std::io::Error> {
use quinn_udp::UdpSockRef;
loop {
match udp_state.send(UdpSockRef::from(&**socket), transmit) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
}
Err(e) => return Err(e),
}
}
}
#[cfg(not(feature = "udp-gso"))]
async fn forward_from_ring_to_udp(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await
}
async fn forward_from_ring_to_udp_fallback(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
tracing::debug!("udp forward from ring to udp (per-packet)");
loop { loop {
let buf = ring_recv.next().await?; let buf = ring_recv.next().await?;
let packet = match buf { let packet = match buf {
@@ -428,6 +317,7 @@ async fn forward_from_ring_to_udp_fallback(
header.msg_type = UdpPacketType::Data as u8; header.msg_type = UdpPacketType::Data as u8;
let buf = packet.into_bytes(); let buf = packet.into_bytes();
tracing::trace!(?udp_payload_len, ?buf, "udp forward from ring to udp");
let ret = socket.send_to(&buf, &addr).await; let ret = socket.send_to(&buf, &addr).await;
if ret.is_err() { if ret.is_err() {
return Some(TunnelError::IOError(ret.unwrap_err())); return Some(TunnelError::IOError(ret.unwrap_err()));
@@ -505,10 +395,7 @@ impl UdpConnection {
} }
} }
pub async fn handle_packet_from_remote( pub fn handle_packet_from_remote(&mut self, zc_packet: ZCPacket) -> Result<(), TunnelError> {
&mut self,
zc_packet: ZCPacket,
) -> Result<(), TunnelError> {
let header = zc_packet.udp_tunnel_header().unwrap(); let header = zc_packet.udp_tunnel_header().unwrap();
let conn_id = header.conn_id.get(); let conn_id = header.conn_id.get();
@@ -520,7 +407,14 @@ impl UdpConnection {
return Err(TunnelError::ConnIdNotMatch(self.conn_id, conn_id)); return Err(TunnelError::ConnIdNotMatch(self.conn_id, conn_id));
} }
self.ring_sender.send(zc_packet).await?; if zc_packet.is_lossy() {
if let Err(e) = self.ring_sender.try_send(zc_packet) {
tracing::trace!(?e, "ring sender full, drop lossy packet");
}
} else if self.ring_sender.force_send(zc_packet).is_err() {
tracing::trace!("ring sender full, reject non-lossy packet");
return Err(TunnelError::BufferFull);
}
Ok(()) Ok(())
} }
@@ -567,8 +461,15 @@ impl UdpTunnelListenerData {
let socket = self.socket.as_ref().unwrap().clone(); let socket = self.socket.as_ref().unwrap().clone();
let sack_buf = new_sack_packet(conn_id, magic).into_bytes(); let sack_buf = new_sack_packet(conn_id, magic).into_bytes();
if let Err(e) = socket.send_to(&sack_buf, remote_addr).await { if self
tracing::error!(?e, "udp send sack packet error"); .sock_map
.get(&remote_addr)
.is_some_and(|conn| conn.conn_id == conn_id)
{
if let Err(e) = socket.send_to(&sack_buf, remote_addr).await {
tracing::error!(?e, "udp resend sack packet error");
}
tracing::debug!(?conn_id, ?remote_addr, "udp duplicate syn, resent sack");
return; return;
} }
@@ -580,15 +481,43 @@ impl UdpTunnelListenerData {
"udp build tunnel for listener" "udp build tunnel for listener"
); );
let internal_conn = UdpConnection::new( let new_internal_conn = || {
socket.clone(), UdpConnection::new(
conn_id, socket.clone(),
remote_addr, conn_id,
RingSink::new(ring_for_recv_udp.clone()), remote_addr,
RingStream::new(ring_for_send_udp.clone()), RingSink::new(ring_for_recv_udp.clone()),
self.close_event_sender.clone(), RingStream::new(ring_for_send_udp.clone()),
); self.close_event_sender.clone(),
self.sock_map.insert(remote_addr, internal_conn); )
};
let duplicate_syn = match self.sock_map.entry(remote_addr) {
dashmap::mapref::entry::Entry::Occupied(entry) if entry.get().conn_id == conn_id => {
true
}
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
entry.insert(new_internal_conn());
false
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(new_internal_conn());
false
}
};
if duplicate_syn {
if let Err(e) = socket.send_to(&sack_buf, remote_addr).await {
tracing::error!(?e, "udp resend sack packet error");
}
tracing::debug!(?conn_id, ?remote_addr, "udp duplicate syn, resent sack");
return;
}
if let Err(e) = socket.send_to(&sack_buf, remote_addr).await {
self.sock_map
.remove_if(&remote_addr, |_, conn| conn.conn_id == conn_id);
tracing::error!(?e, "udp send sack packet error");
return;
}
let conn = Box::new(TunnelWrapper::new( let conn = Box::new(TunnelWrapper::new(
Box::new(RingStream::new(ring_for_recv_udp)), Box::new(RingStream::new(ring_for_recv_udp)),
@@ -612,7 +541,7 @@ impl UdpTunnelListenerData {
} }
} }
async fn do_forward_one_packet_to_conn(&self, zc_packet: ZCPacket, addr: SocketAddr) { fn do_forward_one_packet_to_conn(&self, zc_packet: ZCPacket, addr: SocketAddr) {
let header = zc_packet.udp_tunnel_header().unwrap(); let header = zc_packet.udp_tunnel_header().unwrap();
if header.msg_type == UdpPacketType::Syn as u8 { if header.msg_type == UdpPacketType::Syn as u8 {
tokio::spawn(Self::handle_new_connect(self.clone(), addr, zc_packet)); tokio::spawn(Self::handle_new_connect(self.clone(), addr, zc_packet));
@@ -656,23 +585,61 @@ impl UdpTunnelListenerData {
tracing::warn!(?addr, "v6 hole punch packet should be sent from ipv6"); tracing::warn!(?addr, "v6 hole punch packet should be sent from ipv6");
return; return;
} }
let Some(dst_addr) = extrace_dst_addr_from_hole_punch_packet(zc_packet.udp_payload()) let Some((dst_addr, preferred_src)) =
extract_v6_hole_punch_packet(zc_packet.udp_payload())
else { else {
tracing::warn!("invalid v6 hole punch packet"); tracing::warn!("invalid v6 hole punch packet");
return; return;
}; };
let socket = self.socket.as_ref().unwrap().clone(); let socket = self.socket.as_ref().unwrap().clone();
let udp_packet = new_hole_punch_packet(1, 32); let udp_packet = new_hole_punch_packet(1, 32);
if let Err(e) = socket.try_send_to(&udp_packet.into_bytes(), SocketAddr::V6(dst_addr)) { let udp_packet = udp_packet.into_bytes();
let sent_with_src = if let Some(src) = preferred_src {
match udp_src::send_to_with_src_ipv6(
&socket,
src.ip,
src.ifindex,
dst_addr,
&udp_packet,
) {
Ok(ret) => {
tracing::debug!(
?src,
?dst_addr,
?ret,
"udp forward packet send hole punch packet with preferred ipv6 source"
);
true
}
Err(e) => {
tracing::debug!(
?src,
?dst_addr,
?e,
"udp forward packet preferred ipv6 source failed, falling back"
);
false
}
}
} else {
false
};
if !sent_with_src
&& let Err(e) = socket.try_send_to(&udp_packet, SocketAddr::V6(dst_addr))
{
tracing::error!(?e, "udp send hole punch packet error"); tracing::error!(?e, "udp send hole punch packet error");
} }
tracing::debug!(?dst_addr, "udp forward packet send hole punch packet"); tracing::debug!(
?dst_addr,
?preferred_src,
"udp forward packet send hole punch packet"
);
} else if header.msg_type != UdpPacketType::HolePunch as u8 { } else if header.msg_type != UdpPacketType::HolePunch as u8 {
let Some(mut conn) = self.sock_map.get_mut(&addr) else { let Some(mut conn) = self.sock_map.get_mut(&addr) else {
tracing::trace!(?header, "udp forward packet error, connection not found"); tracing::trace!(?header, "udp forward packet error, connection not found");
return; return;
}; };
if let Err(e) = conn.handle_packet_from_remote(zc_packet).await { if let Err(e) = conn.handle_packet_from_remote(zc_packet) {
tracing::trace!(?e, "udp forward packet error"); tracing::trace!(?e, "udp forward packet error");
} }
} else { } else {
@@ -685,7 +652,7 @@ impl UdpTunnelListenerData {
let mut buf = BytesMut::new(); let mut buf = BytesMut::new();
loop { loop {
match udp_recv_from_socket_forward_task(&socket, &mut buf, true).await { match udp_recv_from_socket_forward_task(&socket, &mut buf, true).await {
Ok((zc_packet, addr)) => self.do_forward_one_packet_to_conn(zc_packet, addr).await, Ok((zc_packet, addr)) => self.do_forward_one_packet_to_conn(zc_packet, addr),
Err(e) => { Err(e) => {
tracing::error!(?e, "udp recv packet error"); tracing::error!(?e, "udp recv packet error");
break; break;
@@ -708,9 +675,8 @@ pub struct UdpTunnelListener {
impl UdpTunnelListener { impl UdpTunnelListener {
pub fn new(addr: url::Url) -> Self { pub fn new(addr: url::Url) -> Self {
let (close_event_send, close_event_recv) = let (close_event_send, close_event_recv) = unbounded_channel();
hotpath::channel!(tokio::sync::mpsc::unbounded_channel()); let (conn_send, conn_recv) = channel(100);
let (conn_send, conn_recv) = hotpath::channel!(tokio::sync::mpsc::channel(100));
Self { Self {
addr: addr.clone(), addr: addr.clone(),
socket: None, socket: None,
@@ -841,6 +807,13 @@ impl UdpTunnelConnector {
} }
} }
fn should_resend_syn_to_hole_punch_source(
recv_addr: SocketAddr,
expected_addr: SocketAddr,
) -> bool {
recv_addr == expected_addr
}
async fn wait_sack( async fn wait_sack(
socket: &UdpSocket, socket: &UdpSocket,
addr: SocketAddr, addr: SocketAddr,
@@ -856,12 +829,34 @@ impl UdpTunnelConnector {
) )
.await??; .await??;
let zc_packet = get_zcpacket_from_buf(buf.split(), false)?; let zc_packet = get_zcpacket_from_buf(buf.split(), false)?;
let header = zc_packet.udp_tunnel_header().unwrap();
if header.msg_type == UdpPacketType::HolePunch as u8 {
tracing::debug!(?recv_addr, ?addr, "udp wait sack got hole punch packet");
if Self::should_resend_syn_to_hole_punch_source(recv_addr, addr) {
let udp_packet = new_syn_packet(conn_id, magic).into_bytes();
match socket.send_to(&udp_packet, recv_addr).await {
Ok(ret) => {
tracing::debug!(?recv_addr, ?ret, "udp send syn to hole punch source")
}
Err(e) => {
tracing::debug!(?recv_addr, ?e, "udp send syn to hole punch source failed")
}
}
} else {
tracing::debug!(
?recv_addr,
?addr,
"ignore hole punch packet from unexpected source"
);
}
return Err(TunnelError::InvalidPacket(
"got hole punch packet while waiting for sack".to_owned(),
));
}
if recv_addr != addr { if recv_addr != addr {
tracing::warn!(?recv_addr, ?addr, ?usize, "udp wait sack addr not match"); tracing::warn!(?recv_addr, ?addr, ?usize, "udp wait sack addr not match");
} }
let header = zc_packet.udp_tunnel_header().unwrap();
if header.conn_id.get() != conn_id { if header.conn_id.get() != conn_id {
return Err(super::TunnelError::ConnIdNotMatch( return Err(super::TunnelError::ConnIdNotMatch(
header.conn_id.get(), header.conn_id.get(),
@@ -921,8 +916,7 @@ impl UdpTunnelConnector {
"udp build tunnel for connector" "udp build tunnel for connector"
); );
let (close_event_sender, mut close_event_recv) = let (close_event_sender, mut close_event_recv) = unbounded_channel();
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
let ring_recv = RingStream::new(ring_for_send_udp.clone()); let ring_recv = RingStream::new(ring_for_send_udp.clone());
let ring_sender = RingSink::new(ring_for_recv_udp.clone()); let ring_sender = RingSink::new(ring_for_recv_udp.clone());
@@ -943,7 +937,7 @@ impl UdpTunnelConnector {
match udp_recv_from_socket_forward_task(&socket_clone, &mut buf, false).await { match udp_recv_from_socket_forward_task(&socket_clone, &mut buf, false).await {
Ok((zc_packet, addr)) => { Ok((zc_packet, addr)) => {
tracing::trace!(?addr, "connector udp forward task done"); tracing::trace!(?addr, "connector udp forward task done");
if let Err(e) = udp_conn.handle_packet_from_remote(zc_packet).await { if let Err(e) = udp_conn.handle_packet_from_remote(zc_packet) {
tracing::trace!(?e, ?addr, "udp forward packet error"); tracing::trace!(?e, ?addr, "udp forward packet error");
} }
} }
@@ -1004,6 +998,23 @@ impl UdpTunnelConnector {
let udp_packet = new_syn_packet(conn_id, magic).into_bytes(); let udp_packet = new_syn_packet(conn_id, magic).into_bytes();
let ret = socket.send_to(&udp_packet, &addr).await?; let ret = socket.send_to(&udp_packet, &addr).await?;
tracing::warn!(?udp_packet, ?ret, "udp send syn"); tracing::warn!(?udp_packet, ?ret, "udp send syn");
let resend_task = AbortOnDropHandle::new(tokio::spawn({
let socket = socket.clone();
let udp_packet = udp_packet.clone();
let resend_addr = addr;
async move {
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
match socket.send_to(&udp_packet, &resend_addr).await {
Ok(ret) => tracing::trace!(?ret, ?resend_addr, "udp resend syn"),
Err(e) => {
tracing::debug!(?e, ?resend_addr, "udp resend syn failed");
break;
}
}
}
}
}));
// wait sack // wait sack
let recv_addr = tokio::time::timeout( let recv_addr = tokio::time::timeout(
@@ -1011,12 +1022,13 @@ impl UdpTunnelConnector {
Self::wait_sack_loop(&socket, addr, conn_id, magic), Self::wait_sack_loop(&socket, addr, conn_id, magic),
) )
.await??; .await??;
drop(resend_task);
if recv_addr != addr { if recv_addr != addr {
tracing::debug!(?recv_addr, ?addr, "udp connect addr not match"); tracing::debug!(?recv_addr, ?addr, "udp connect addr not match");
} }
self.build_tunnel(socket, addr, conn_id).await self.build_tunnel(socket, recv_addr, conn_id).await
} }
async fn connect_with_default_bind( async fn connect_with_default_bind(
@@ -1115,9 +1127,43 @@ mod tests {
get_interface_name_by_ip, get_interface_name_by_ip,
tests::{_tunnel_bench, _tunnel_echo_server, _tunnel_pingpong, wait_for_condition}, tests::{_tunnel_bench, _tunnel_echo_server, _tunnel_pingpong, wait_for_condition},
}, },
packet_def::PacketType,
}, },
}; };
fn new_udp_data_packet(conn_id: u32, packet_type: PacketType) -> ZCPacket {
let mut packet = ZCPacket::new_with_payload(b"udp-data").convert_type(ZCPacketType::UDP);
packet.fill_peer_manager_hdr(1, 2, packet_type as u8);
let udp_payload_len = packet.udp_payload().len();
let header = packet.mut_udp_tunnel_header().unwrap();
header.conn_id.set(conn_id);
header.msg_type = UdpPacketType::Data as u8;
header.len.set(udp_payload_len as u16);
packet
}
fn assert_sync_packet_handler(_: fn(&mut UdpConnection, ZCPacket) -> Result<(), TunnelError>) {}
#[test]
fn hole_punch_source_must_match_connect_addr_before_syn_resend() {
let expected_addr: SocketAddr = "198.51.100.10:11010".parse().unwrap();
let same_port_different_ip: SocketAddr = "198.51.100.11:11010".parse().unwrap();
let same_ip_different_port: SocketAddr = "198.51.100.10:11011".parse().unwrap();
assert!(UdpTunnelConnector::should_resend_syn_to_hole_punch_source(
expected_addr,
expected_addr
));
assert!(!UdpTunnelConnector::should_resend_syn_to_hole_punch_source(
same_port_different_ip,
expected_addr
));
assert!(!UdpTunnelConnector::should_resend_syn_to_hole_punch_source(
same_ip_different_port,
expected_addr
));
}
#[tokio::test] #[tokio::test]
async fn udp_pingpong() { async fn udp_pingpong() {
let listener = UdpTunnelListener::new("udp://0.0.0.0:5556".parse().unwrap()); let listener = UdpTunnelListener::new("udp://0.0.0.0:5556".parse().unwrap());
@@ -1125,6 +1171,43 @@ mod tests {
_tunnel_pingpong(listener, connector).await; _tunnel_pingpong(listener, connector).await;
} }
#[tokio::test]
async fn udp_connection_handler_uses_sync_nonblocking_ring_delivery() {
assert_sync_packet_handler(UdpConnection::handle_packet_from_remote);
let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let dst_addr = "127.0.0.1:1".parse().unwrap();
let ring_for_send_udp = Arc::new(RingTunnel::new(8));
let ring_for_recv_udp = Arc::new(RingTunnel::new(8));
let (close_event_sender, _close_event_recv) = tokio::sync::mpsc::unbounded_channel();
let mut conn = UdpConnection::new(
socket,
7,
dst_addr,
RingSink::new(ring_for_recv_udp),
RingStream::new(ring_for_send_udp),
close_event_sender,
);
for _ in 0..16 {
conn.handle_packet_from_remote(new_udp_data_packet(7, PacketType::Data))
.unwrap();
}
let mut got_buffer_full = false;
for _ in 0..16 {
match conn.handle_packet_from_remote(new_udp_data_packet(7, PacketType::Ping)) {
Ok(()) => {}
Err(TunnelError::BufferFull) => {
got_buffer_full = true;
break;
}
Err(e) => panic!("unexpected error: {e:?}"),
}
}
assert!(got_buffer_full);
}
#[tokio::test] #[tokio::test]
async fn udp_bench() { async fn udp_bench() {
let listener = UdpTunnelListener::new("udp://0.0.0.0:5555".parse().unwrap()); let listener = UdpTunnelListener::new("udp://0.0.0.0:5555".parse().unwrap());
@@ -1348,6 +1431,22 @@ mod tests {
.await; .await;
} }
#[test]
fn v6_hole_punch_packet_preserves_preferred_source_ifindex() {
let dst_addr = "[2001:db8::1]:10001".parse::<SocketAddrV6>().unwrap();
let preferred_src = PreferredIpv6Source {
ip: "2001:db8::2".parse().unwrap(),
ifindex: 42,
};
let packet = new_v6_hole_punch_packet(&dst_addr, Some(preferred_src));
let (parsed_dst_addr, parsed_preferred_src) =
extract_v6_hole_punch_packet(packet.udp_payload()).unwrap();
assert_eq!(parsed_dst_addr, dst_addr);
assert_eq!(parsed_preferred_src, Some(preferred_src));
}
#[tokio::test] #[tokio::test]
async fn test_v6_hole_punch_packet() { async fn test_v6_hole_punch_packet() {
let mut lis = UdpTunnelListener::new("udp://[::]:0".parse().unwrap()); let mut lis = UdpTunnelListener::new("udp://[::]:0".parse().unwrap());
@@ -1374,6 +1473,7 @@ mod tests {
std::net::SocketAddr::V6(addr_v6) => addr_v6, std::net::SocketAddr::V6(addr_v6) => addr_v6,
_ => panic!("Expected an IPv6 address"), _ => panic!("Expected an IPv6 address"),
}, },
None,
) )
.await .await
.unwrap(); .unwrap();
+210
View File
@@ -0,0 +1,210 @@
use std::{
io,
net::{Ipv6Addr, SocketAddrV6},
};
use tokio::net::UdpSocket;
#[cfg(unix)]
pub(crate) fn send_to_with_src_ipv6(
socket: &UdpSocket,
src_ip: Ipv6Addr,
src_ifindex: u32,
dst_addr: SocketAddrV6,
buf: &[u8],
) -> io::Result<usize> {
#[cfg(target_env = "ohos")]
{
let _ = (socket, src_ip, src_ifindex, dst_addr, buf);
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"sending UDP with a selected IPv6 source is not supported on OHOS",
));
}
#[cfg(not(target_env = "ohos"))]
{
use std::{mem, os::fd::AsRawFd, ptr};
use nix::libc;
#[repr(align(8))]
struct ControlBuffer([u8; 128]);
#[cfg(target_os = "android")]
let ipi6_ifindex: libc::c_int = i32::try_from(src_ifindex).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"IPv6 source interface index is out of range",
)
})?;
#[cfg(not(target_os = "android"))]
let ipi6_ifindex: libc::c_uint = src_ifindex;
let pktinfo = libc::in6_pktinfo {
ipi6_addr: libc::in6_addr {
s6_addr: src_ip.octets(),
},
ipi6_ifindex,
};
let mut iov = libc::iovec {
iov_base: buf.as_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
let dst_addr = socket2::SockAddr::from(std::net::SocketAddr::V6(dst_addr));
let control_len = unsafe {
libc::CMSG_SPACE(mem::size_of::<libc::in6_pktinfo>() as libc::c_uint) as usize
};
let mut control = ControlBuffer([0u8; 128]);
if control_len > control.0.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"IPv6 packet info control buffer is too small",
));
}
let mut msg = unsafe { mem::zeroed::<libc::msghdr>() };
msg.msg_name = dst_addr.as_ptr() as *mut libc::c_void;
msg.msg_namelen = dst_addr.len() as _;
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.0.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = control_len as _;
msg.msg_flags = 0;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"IPv6 packet info control buffer is invalid",
));
}
(*cmsg).cmsg_level = libc::IPPROTO_IPV6;
(*cmsg).cmsg_type = libc::IPV6_PKTINFO;
(*cmsg).cmsg_len =
libc::CMSG_LEN(mem::size_of::<libc::in6_pktinfo>() as libc::c_uint) as _;
ptr::write(libc::CMSG_DATA(cmsg) as *mut libc::in6_pktinfo, pktinfo);
let ret = libc::sendmsg(socket.as_raw_fd(), &msg, 0);
if ret < 0 {
Err(io::Error::last_os_error())
} else {
Ok(ret as usize)
}
}
}
}
#[cfg(windows)]
pub(crate) fn send_to_with_src_ipv6(
socket: &UdpSocket,
src_ip: Ipv6Addr,
src_ifindex: u32,
dst_addr: SocketAddrV6,
buf: &[u8],
) -> io::Result<usize> {
use std::{mem, os::windows::io::AsRawSocket, ptr};
use windows::{
Win32::Networking::WinSock::{
CMSGHDR, IN6_ADDR, IN6_ADDR_0, IN6_PKTINFO, IPPROTO_IPV6, IPV6_PKTINFO, SOCKET,
SOCKET_ERROR, WSABUF, WSAGetLastError, WSAMSG, WSASendMsg,
},
core::PSTR,
};
fn cmsghdr_align(length: usize) -> usize {
(length + mem::align_of::<CMSGHDR>() - 1) & !(mem::align_of::<CMSGHDR>() - 1)
}
fn cmsgdata_align(length: usize) -> usize {
(length + mem::align_of::<usize>() - 1) & !(mem::align_of::<usize>() - 1)
}
fn cmsg_len(length: usize) -> usize {
cmsgdata_align(mem::size_of::<CMSGHDR>()) + length
}
fn cmsg_space(length: usize) -> usize {
cmsgdata_align(mem::size_of::<CMSGHDR>() + cmsghdr_align(length))
}
fn cmsg_data(cmsg: *mut CMSGHDR) -> *mut u8 {
(cmsg as usize + cmsgdata_align(mem::size_of::<CMSGHDR>())) as *mut u8
}
#[repr(align(8))]
struct ControlBuffer([u8; 128]);
let dst = socket2::SockAddr::from(std::net::SocketAddr::V6(dst_addr));
let mut data = WSABUF {
len: buf.len() as u32,
buf: PSTR(buf.as_ptr() as *mut u8),
};
let control_len = cmsg_space(mem::size_of::<IN6_PKTINFO>());
let mut control = ControlBuffer([0u8; 128]);
if control_len > control.0.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"IPv6 packet info control buffer is too small",
));
}
let mut msg = WSAMSG {
name: dst.as_ptr() as *mut _,
namelen: dst.len(),
lpBuffers: &mut data,
dwBufferCount: 1,
Control: WSABUF {
len: control_len as u32,
buf: PSTR(control.0.as_mut_ptr()),
},
dwFlags: 0,
};
let pktinfo = IN6_PKTINFO {
ipi6_addr: IN6_ADDR {
u: IN6_ADDR_0 {
Byte: src_ip.octets(),
},
},
ipi6_ifindex: src_ifindex,
};
unsafe {
let cmsg = control.0.as_mut_ptr() as *mut CMSGHDR;
(*cmsg).cmsg_level = IPPROTO_IPV6.0;
(*cmsg).cmsg_type = IPV6_PKTINFO;
(*cmsg).cmsg_len = cmsg_len(mem::size_of::<IN6_PKTINFO>());
ptr::write(cmsg_data(cmsg) as *mut IN6_PKTINFO, pktinfo);
msg.Control.len = control_len as u32;
let mut sent = 0;
let ret = WSASendMsg(
SOCKET(socket.as_raw_socket() as usize),
&msg,
0,
Some(&mut sent),
None,
None,
);
if ret == SOCKET_ERROR {
return Err(io::Error::from_raw_os_error(WSAGetLastError().0));
}
Ok(sent as usize)
}
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn send_to_with_src_ipv6(
_socket: &UdpSocket,
_src_ip: Ipv6Addr,
_src_ifindex: u32,
_dst_addr: SocketAddrV6,
_buf: &[u8],
) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"sending UDP with a selected IPv6 source is not supported on this platform",
))
}
+7 -3
View File
@@ -6,7 +6,7 @@ use std::{
time::Duration, time::Duration,
}; };
use hotpath::instant::Instant; use quanta::Instant;
use super::{ use super::{
FromUrl, IpVersion, Tunnel, TunnelError, TunnelInfo, TunnelListener, TunnelUrl, ZCPacketSink, FromUrl, IpVersion, Tunnel, TunnelError, TunnelInfo, TunnelListener, TunnelUrl, ZCPacketSink,
@@ -37,7 +37,11 @@ use crossbeam::atomic::AtomicCell;
use dashmap::DashMap; use dashmap::DashMap;
use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; use futures::{SinkExt, StreamExt, stream::FuturesUnordered};
use rand::RngCore; use rand::RngCore;
use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet}; use tokio::{
net::UdpSocket,
sync::{Mutex, mpsc::unbounded_channel},
task::JoinSet,
};
const MAX_PACKET: usize = 2048; const MAX_PACKET: usize = 2048;
@@ -470,7 +474,7 @@ pub struct WgTunnelListener {
impl WgTunnelListener { impl WgTunnelListener {
pub fn new(addr: url::Url, config: WgConfig) -> Self { pub fn new(addr: url::Url, config: WgConfig) -> Self {
let (conn_send, conn_recv) = hotpath::channel!(tokio::sync::mpsc::unbounded_channel()); let (conn_send, conn_recv) = unbounded_channel();
WgTunnelListener { WgTunnelListener {
addr, addr,
config, config,

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