Compare commits

...
6 Commits
Author SHA1 Message Date
fanyang 30926aec55 feat(cli): support configurable local state directory
Add --state-dir and ET_STATE_DIR for persisted web-client machine IDs.

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

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

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

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

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

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

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

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

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

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

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

Fixes #2607

* ci: remove the separate rustls backend regression step

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

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

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

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

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

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

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

Add 'pkexec | policykit-1' to the deb bundle dependencies. The
alternation keeps the package installable on older releases where
pkexec still lives inside policykit-1 (e.g. Ubuntu 22.04), while
tauri-bundler continues to append the auto-detected webkit2gtk/gtk
dependencies.
2026-10-09 00:02:37 +08:00
刚刚 4837468d43 fix(gui/web): 修复丢包率和 NAT 类型显示 (#2643) 2026-10-08 00:51:06 +08:00
Luna Yao eb32d2a930 fix(utils): fix some bugs in BufPoolWriter/BufList (#2634)
fix bugs introduced by feat(buf): add BufPool, BufMargins, and BufList for zero-copy packet buffering #2625
cf. perf(quic-proxy): use BufPool and BufMargins in QuicSocket #2626 (comment)
2026-10-07 21:58:46 +08:00
KKRainbow 728ba94b50 Link easytier-mini downloads and fix UDP admission test ordering (#2638) 2026-10-07 01:03:35 +08:00
44 changed files with 711 additions and 294 deletions
Generated
+36 -112
View File
@@ -75,25 +75,11 @@ dependencies = [
"aead 0.5.2",
"aes 0.8.4",
"cipher 0.4.4",
"ctr 0.9.2",
"ghash 0.5.1",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aes-gcm"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead 0.6.1",
"aes 0.9.3",
"cipher 0.5.2",
"ctr 0.10.1",
"ctutils",
"ghash 0.6.0",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -1957,15 +1943,6 @@ dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -2537,7 +2514,7 @@ dependencies = [
"atomic-shim",
"atomic-write-file",
"atomic_refcell",
"base64 0.23.1",
"base64 0.22.1",
"bon",
"boringtun-easytier",
"bytecodec",
@@ -2570,7 +2547,7 @@ dependencies = [
"hickory-proto",
"hickory-resolver",
"hickory-server",
"hkdf 0.13.0",
"hkdf",
"http",
"http-body-util",
"humansize",
@@ -2609,7 +2586,7 @@ dependencies = [
"serde_json",
"serial_test",
"service-manager",
"sha2 0.11.0",
"sha2 0.10.9",
"shellexpand",
"smoltcp",
"socket2",
@@ -2663,7 +2640,7 @@ dependencies = [
name = "easytier-core"
version = "2.7.0"
dependencies = [
"aes-gcm 0.11.1",
"aes-gcm",
"anyhow",
"arc-swap",
"ariadne",
@@ -2671,22 +2648,22 @@ dependencies = [
"async-trait",
"atomic-shim",
"auto_impl",
"base64 0.23.1",
"base64 0.22.1",
"bitflags 2.13.2",
"bon",
"bytecodec",
"bytes",
"chacha20poly1305 0.11.0",
"chacha20poly1305 0.10.1",
"chrono",
"cidr",
"crossbeam",
"crossbeam-utils",
"dashmap",
"easytier-proto",
"futures",
"getrandom 0.2.17",
"getrandom 0.3.4",
"guarden",
"hmac 0.13.0",
"hmac 0.12.1",
"http-body-util",
"hyper",
"hyper-util",
@@ -2704,9 +2681,10 @@ dependencies = [
"rand 0.8.8",
"ring",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"smoltcp",
"snow",
"strum 0.28.0",
@@ -2811,7 +2789,7 @@ dependencies = [
name = "easytier-ohos-features"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"easytier",
"flate2",
"gethostname",
@@ -2858,11 +2836,11 @@ dependencies = [
"anyhow",
"async-trait",
"auto_impl",
"base64 0.23.1",
"base64 0.22.1",
"bytes",
"chrono",
"cidr",
"hmac 0.13.0",
"hmac 0.12.1",
"indoc",
"pbjson",
"pbjson-build",
@@ -2872,10 +2850,10 @@ dependencies = [
"prost-types 0.14.4",
"prost-wkt-types",
"quote",
"reqwest 0.13.5",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"thiserror 2.0.20",
"tokio",
"url",
@@ -2924,7 +2902,7 @@ dependencies = [
"axum-embed",
"axum-login",
"axum-messages",
"base64 0.23.1",
"base64 0.22.1",
"chrono",
"cidr",
"clap",
@@ -2937,18 +2915,19 @@ dependencies = [
"imageproc",
"maxminddb",
"mimalloc",
"oauth2-reqwest",
"openidconnect",
"password-auth",
"prost 0.14.4",
"rand 0.8.8",
"reqwest 0.13.5",
"reqwest",
"rust-embed",
"rust-i18n",
"sea-orm",
"sea-orm-migration",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"sqlx",
"subtle",
"sys-locale",
@@ -3025,7 +3004,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf 0.12.4",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -3778,16 +3757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval 0.6.2",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval 0.7.3",
"polyval",
]
[[package]]
@@ -4297,15 +4267,6 @@ dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac 0.13.0",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -6070,7 +6031,6 @@ dependencies = [
"getrandom 0.2.17",
"http",
"rand 0.8.8",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_path_to_error",
@@ -6079,6 +6039,16 @@ dependencies = [
"url",
]
[[package]]
name = "oauth2-reqwest"
version = "0.1.0-alpha.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
dependencies = [
"oauth2",
"reqwest",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -7001,17 +6971,6 @@ dependencies = [
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
dependencies = [
"cpubits",
"cpufeatures 0.3.1",
"universal-hash 0.6.1",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -7743,38 +7702,6 @@ version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.5"
@@ -8979,12 +8906,9 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
dependencies = [
"aes-gcm 0.10.3",
"blake2 0.10.6",
"chacha20poly1305 0.10.1",
"curve25519-dalek 4.1.3",
"getrandom 0.3.4",
"ring",
"rustc_version",
"sha2 0.10.9",
"subtle",
@@ -9182,7 +9106,7 @@ dependencies = [
"futures-util",
"generic-array",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"itoa",
"log",
@@ -9225,7 +9149,7 @@ dependencies = [
"futures-core",
"futures-util",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"home",
"itoa",
@@ -9633,7 +9557,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.5",
"reqwest",
"serde",
"serde_json",
"serde_repr",
+9 -4
View File
@@ -29,7 +29,8 @@ async-trait = "0.1.92"
atomic-shim = "0.2.0"
auto_impl = "1.3.0"
axum = "0.8"
base64 = "0.23"
# Match pbjson 0.9's base64 dependency; upgrade together to avoid two codecs.
base64 = "0.22.1"
bon = "3.10.1"
bytecodec = "0.5.0"
bytes = "1.12.1"
@@ -37,6 +38,7 @@ chrono = "0.4.45"
cidr = "0.3.2"
clap = "4.6.7"
crossbeam = "0.8.5"
crossbeam-utils = "0.8.23"
dashmap = "6.2.1"
easytier = { version = "2.7.0", path = "easytier", default-features = false }
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
@@ -44,10 +46,12 @@ easytier-ffi = { version = "0.1.0", path = "easytier-contrib/easytier-ffi", defa
easytier-ohos-core = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-core", default-features = false }
easytier-ohos-features = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-features", default-features = false }
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
futures = "0.3"
futures = { version = "0.3", default-features = false }
gethostname = "1.1"
guarden = "0.3"
hmac = "0.13.0"
# Keep HMAC/SHA2 on digest 0.10, shared by stun_codec 0.4 and snow 0.10.
# Upgrade this crypto family together when those upstream constraints move.
hmac = "0.12.1"
http-body-util = "0.1"
hyper = { version = "1", default-features = false }
hyper-util = { version = "0.1", default-features = false }
@@ -71,7 +75,8 @@ sea-orm = "1.1.20"
sea-orm-migration = "1.1.20"
serde = "1.0.229"
serde_json = "1.0"
sha2 = "0.11.0"
# See the HMAC constraint above; SHA2 must use the same digest generation.
sha2 = "0.10.9"
smoltcp = { version = "0.14.0", default-features = false }
sqlx = "0.8.6"
strum = "0.28.0"
+2
View File
@@ -71,6 +71,8 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
For a smaller executable, try [easytier-mini](https://github.com/EasyTier/easytier-mini), which supports basic TCP/UDP networking. See its documentation for supported features and usage.
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
+2
View File
@@ -71,6 +71,8 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
需要更小的可执行文件,可使用 [easytier-mini](https://github.com/EasyTier/easytier-mini):支持基础 TCP/UDP 组网,功能范围与使用方式见其文档。
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
+5
View File
@@ -6,6 +6,11 @@ dynamic IPv4 allocation, the smoltcp userspace path and STUN/UDP hole-punching
core with the full binary. It includes AES-GCM so its default encryption
setting interoperates with the full binary's default configuration.
Download [pre-built binaries](https://github.com/EasyTier/easytier-mini/releases)
from the separate [easytier-mini repository](https://github.com/EasyTier/easytier-mini),
which handles builds and releases. The source code is maintained here in the
main EasyTier repository.
Build it with:
```sh
+1 -1
View File
@@ -20,7 +20,7 @@ easytier-proto = { workspace = true, features = [
"core",
"json-rpc",
] }
futures.workspace = true
futures = { workspace = true, features = ["default"] }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
"serde-json",
+25 -10
View File
@@ -30,11 +30,11 @@ bytecodec.workspace = true
bytes.workspace = true
chrono = { workspace = true, features = ["clock"] }
cidr = { workspace = true, features = ["serde"] }
crossbeam.workspace = true
crossbeam-utils.workspace = true
dashmap.workspace = true
bon.workspace = true
easytier-proto = { workspace = true, features = ["core"] }
futures.workspace = true
futures = { workspace = true, features = ["std", "async-await"] }
guarden.workspace = true
hmac.workspace = true
http-body-util = { workspace = true, optional = true }
@@ -58,6 +58,14 @@ serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
smoltcp = { workspace = true, optional = true }
# All core Noise handshakes use 25519_ChaChaPoly_SHA256. Snow's std feature
# also enables unused ring and BLAKE2 dependencies, so use its alloc support.
snow = { version = "0.10.0", default-features = false, features = [
"use-chacha20poly1305",
"use-sha2",
"use-curve25519",
"use-getrandom",
] }
stun_codec.workspace = true
thiserror.workspace = true
tracing.workspace = true
@@ -74,25 +82,25 @@ tokio-util = { workspace = true, features = ["io", "rt"] }
tokio-rustls = { workspace = true, optional = true }
url = { workspace = true, features = ["serde"] }
wildmatch = "2.6.1"
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
uuid = { workspace = true, features = ["v4", "serde"] }
webpki-roots = { version = "1.0", optional = true }
x25519-dalek = { workspace = true, features = ["static_secrets"] }
zerocopy = { workspace = true, features = ["derive", "simd"] }
zstd = { version = "0.14", optional = true }
aes-gcm = { version = "0.11.1", optional = true }
chacha20poly1305 = { version = "0.11.0", optional = true }
# Match snow 0.10's AEAD generation to share aead, cipher and crypto-common.
# Upgrade together with snow rather than pulling in a second crypto stack.
aes-gcm = { version = "0.10.3", optional = true }
chacha20poly1305 = { version = "0.10.1", optional = true }
openssl = { version = "0.10", optional = true, features = ["vendored"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["js"] }
getrandom-03 = { package = "getrandom", version = "0.3.4", features = ["wasm_js"] }
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"], optional = true }
rustls-pki-types = { version = "1.15.1", features = ["web"], optional = true }
uuid = { workspace = true, features = ["js"] }
wasm-bindgen = "0.2"
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
snow = "0.10.0"
[features]
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
aes-gcm = ["dep:aes-gcm"]
@@ -106,7 +114,9 @@ endpoint-discovery = [
"dep:http-body-util",
"dep:hyper",
"dep:hyper-util",
"dep:ring",
"dep:rustls",
"dep:rustls-pki-types",
"dep:tokio-rustls",
"dep:webpki-roots",
]
@@ -152,12 +162,17 @@ test-utils = []
tracing-log = ["tracing/log"]
zstd = ["dep:zstd"]
[dev-dependencies]
futures = { workspace = true, features = ["executor"] }
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
[package.metadata.cargo-machete]
ignored = [
# Enable browser entropy backends for transitive rand/snow dependencies.
# Enable browser entropy backends for transitive rand/snow/AEAD dependencies.
"getrandom-02",
"getrandom-03",
# Enable browser time for rustls certificate validation.
"rustls-pki-types",
]
@@ -8,7 +8,7 @@ use std::{
};
use anyhow::Context;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use quanta::Instant;
use rand::{Rng, seq::SliceRandom as _};
use tokio::{
@@ -187,9 +187,16 @@ where
let root_store = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
// A host application can enable another rustls backend through Cargo
// feature unification. Select ring explicitly instead of requiring a
// process-wide provider to have been initialized first (#2607).
let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.context("selecting HTTPS protocol versions failed")?
.with_root_certificates(root_store)
.with_no_client_auth();
let stream = TlsConnector::from(Arc::new(tls_config))
.connect(server_name, socket)
.await
@@ -631,6 +638,44 @@ mod tests {
assert_eq!(options.bind.context.socket_mark, Some(9));
}
#[tokio::test]
async fn https_fetch_reaches_tls_handshake_without_global_provider() {
// Also run with rustls/aws_lc_rs enabled: feature unification must not
// make HTTPS discovery panic before it can send a ClientHello (#2607).
let (client, mut server) = tokio::io::duplex(8192);
let host = Arc::new(HttpTestHost {
stream: Mutex::new(Some(client)),
connects: Mutex::new(Vec::new()),
});
let dns = HttpTestDns {
queries: Mutex::new(Vec::new()),
};
let server_task = tokio::spawn(async move {
let mut record_header = [0; 5];
server.read_exact(&mut record_header).await.unwrap();
assert_eq!(record_header[0], 22, "expected a TLS handshake record");
// Close without replying; discovery should report a handshake
// error rather than panic while selecting a crypto provider.
});
let error = fetch_http_discovery(
host,
&dns,
HttpDiscoveryRequest {
url: "https://discovery.example/lookup".parse().unwrap(),
user_agent: "easytier/test".to_owned(),
network_name: "test-network".to_owned(),
timeout: Duration::from_secs(5),
ip_version: IpVersion::V4,
tcp_bind: TcpBindOptions::default(),
},
)
.await
.expect_err("server closed during the TLS handshake");
assert!(format!("{error:#}").contains("HTTPS handshake failed"));
server_task.await.unwrap();
}
#[test]
fn http_discovery_interprets_redirect_and_body_forms() {
let query = resolve_http_endpoint(HttpDiscoveryResponse {
@@ -6,7 +6,7 @@ use std::{
sync::{Arc, Mutex},
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
+6 -10
View File
@@ -9,7 +9,7 @@ use std::{
time::Duration,
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use quanta::Instant;
use tokio::{
@@ -892,15 +892,8 @@ mod tests {
response_tasks.insert(replacement.clone(), pending_response_task(replacement_slot));
drop(admission_guard);
tokio::time::timeout(Duration::from_secs(1), async {
while clients.contains_key(&replacement) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(!response_tasks.contains_key(&replacement));
// Release the test's permit reference and wait for the full eviction;
// the client and response-task entries are removed separately.
drop(replacement_client);
let second_slot = tokio::time::timeout(Duration::from_secs(1), second)
.await
@@ -908,6 +901,9 @@ mod tests {
.unwrap()
.unwrap();
assert!(!clients.contains_key(&replacement));
assert!(!response_tasks.contains_key(&replacement));
drop(second_slot);
tokio::task::yield_now().await;
assert_eq!(slots.available_permits(), 1);
@@ -8,7 +8,7 @@ use std::{
};
use cidr::Ipv4Inet;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::{DashMap, mapref::entry::Entry};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::{DashMap, DashSet};
use parking_lot::{Mutex, RwLock};
+1 -1
View File
@@ -1,5 +1,5 @@
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use futures::{StreamExt, TryFutureExt};
use std::{
any::Any,
+1 -1
View File
@@ -5,7 +5,7 @@ use std::sync::{
use std::time::{Duration, Instant};
use anyhow::anyhow;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use crate::peers::util::shrink_dashmap;
+1 -1
View File
@@ -16,7 +16,7 @@ use easytier_proto::{
common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
peer_rpc::{PeerGroupInfo, TrustedCredentialPubkeyProof},
};
use hmac::{Hmac, KeyInit, Mac};
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub use crate::config::{NetworkIdentity, NetworkSecretDigest};
@@ -4,7 +4,7 @@ use std::{
time::{Duration, Instant},
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use futures::Future;
use std::sync::RwLock;
use tokio::sync::Mutex;
@@ -12,7 +12,7 @@ use std::{
use arc_swap::ArcSwap;
use atomic_shim::AtomicU64;
use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use ordered_hash_map::OrderedHashMap;
use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard};
+65 -37
View File
@@ -15,23 +15,24 @@ impl<T: Buf> BufList<T> {
}
}
#[inline]
#[inline(always)]
pub fn push(&mut self, buf: T) {
debug_assert!(buf.has_remaining());
self.bufs.push_back(buf);
if buf.has_remaining() {
self.bufs.push_back(buf);
}
}
#[inline]
#[inline(always)]
pub fn pop(&mut self) -> Option<T> {
self.bufs.pop_front()
}
#[inline]
#[inline(always)]
pub fn len(&self) -> usize {
self.bufs.len()
}
#[inline]
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.bufs.is_empty()
}
@@ -39,17 +40,15 @@ impl<T: Buf> BufList<T> {
impl<T: Buf> Extend<T> for BufList<T> {
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
self.bufs.extend(
iter.into_iter()
.inspect(|buf| debug_assert!(buf.has_remaining())),
);
self.bufs
.extend(iter.into_iter().filter(Buf::has_remaining));
}
}
impl<T: Buf> Buf for BufList<T> {
#[inline]
fn remaining(&self) -> usize {
self.bufs.iter().map(|buf| buf.remaining()).sum()
self.bufs.iter().map(Buf::remaining).sum()
}
#[inline]
@@ -59,33 +58,30 @@ impl<T: Buf> Buf for BufList<T> {
#[inline]
fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize {
if dst.is_empty() {
return 0;
}
let mut vecs = 0;
for buf in &self.bufs {
vecs += buf.chunks_vectored(&mut dst[vecs..]);
if vecs == dst.len() {
let n = buf.chunks_vectored(&mut dst[vecs..]);
vecs += n;
if dst[vecs - n..vecs].iter().map(|s| s.len()).sum::<usize>() < buf.remaining() {
break;
}
}
vecs
}
#[inline]
fn advance(&mut self, mut cnt: usize) {
while cnt > 0 {
{
let front = &mut self.bufs[0];
let rem = front.remaining();
if rem > cnt {
front.advance(cnt);
return;
} else {
front.advance(rem);
cnt -= rem;
}
let front = &mut self.bufs[0];
let rem = front.remaining();
front.advance(cnt.min(rem));
if rem > cnt {
return;
}
cnt -= rem;
self.bufs.pop_front();
}
}
@@ -94,20 +90,21 @@ impl<T: Buf> Buf for BufList<T> {
fn copy_to_bytes(&mut self, len: usize) -> Bytes {
// Our inner buffer may have an optimized version of copy_to_bytes, and if the whole
// request can be fulfilled by the front buffer, we can take advantage.
match self.bufs.front_mut() {
Some(front) if front.remaining() == len => {
let b = front.copy_to_bytes(len);
if let Some(front) = self.bufs.front_mut()
&& let rem = front.remaining()
&& len <= rem
{
let bytes = front.copy_to_bytes(len);
if len == rem {
self.bufs.pop_front();
b
}
Some(front) if front.remaining() > len => front.copy_to_bytes(len),
_ => {
assert!(len <= self.remaining(), "`len` greater than remaining");
let mut bm = BytesMut::with_capacity(len);
bm.put(self.take(len));
bm.freeze()
}
return bytes;
}
assert!(len <= self.remaining());
let mut bytes = BytesMut::with_capacity(len);
bytes.put(self.take(len));
bytes.freeze()
}
}
@@ -125,4 +122,35 @@ mod tests {
assert_eq!(&bytes[..], b"hello world");
assert_eq!(list.remaining(), 0);
}
#[test]
fn test_chunks_vectored_partial_inner_buf() {
#[derive(Debug)]
struct PartialBuf(&'static [u8]);
impl Buf for PartialBuf {
fn remaining(&self) -> usize {
self.0.len()
}
fn chunk(&self) -> &[u8] {
&self.0[..self.0.len().min(1)]
}
fn advance(&mut self, cnt: usize) {
self.0 = &self.0[cnt..];
}
}
let mut list = BufList::new();
list.push(PartialBuf(b"ab"));
list.push(PartialBuf(b"c"));
let mut dst = [IoSlice::new(&[]); 3];
let n = list.chunks_vectored(&mut dst);
assert_eq!(n, 1);
assert_eq!(&*dst[0], b"a");
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
use aes_gcm::{AeadInOut, Aes128Gcm, Aes256Gcm, Key, KeyInit};
use aes_gcm::{AeadInPlace, Aes128Gcm, Aes256Gcm, Key, KeyInit};
use rand::{RngCore, rngs::OsRng};
use zerocopy::{AsBytes, FromBytes};
@@ -54,16 +54,16 @@ impl Encryptor for AesGcmCipher {
let tag = aes_tail.tag.into();
let rs = match &self.cipher {
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
&nonce,
&[],
(&mut zc_packet.mut_payload()[..text_len]).into(),
&mut zc_packet.mut_payload()[..text_len],
&tag,
),
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
&nonce,
&[],
(&mut zc_packet.mut_payload()[..text_len]).into(),
&mut zc_packet.mut_payload()[..text_len],
&tag,
),
};
@@ -113,7 +113,7 @@ impl Encryptor for AesGcmCipher {
nonce.into()
});
(
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
nonce,
)
}
@@ -124,7 +124,7 @@ impl Encryptor for AesGcmCipher {
nonce.into()
});
(
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
nonce,
)
}
+3 -8
View File
@@ -1,4 +1,4 @@
use chacha20poly1305::{AeadInOut, ChaCha20Poly1305, Key, KeyInit};
use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, Key, KeyInit};
use rand::{RngCore, rngs::OsRng};
use zerocopy::{AsBytes, FromBytes};
@@ -42,12 +42,7 @@ impl Encryptor for ChaCha20Cipher {
let tag = tail.tag.into();
self.cipher
.decrypt_inout_detached(
&nonce,
&[],
(&mut zc_packet.mut_payload()[..text_len]).into(),
&tag,
)
.decrypt_in_place_detached(&nonce, &[], &mut zc_packet.mut_payload()[..text_len], &tag)
.map_err(|_| Error::DecryptionFailed)?;
let pm_header = zc_packet.mut_peer_manager_header().unwrap();
@@ -89,7 +84,7 @@ impl Encryptor for ChaCha20Cipher {
let tag = self
.cipher
.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into())
.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload())
.map_err(|_| Error::EncryptionFailed)?;
let tail = StandardAeadTail {
+1 -1
View File
@@ -8,7 +8,7 @@ use std::{
use anyhow::anyhow;
use atomic_shim::AtomicU64;
use hmac::{Hmac, KeyInit as _, Mac as _};
use hmac::{Hmac, Mac as _};
use rand::RngCore as _;
use sha2::Sha256;
use zerocopy::FromBytes;
+1
View File
@@ -1443,6 +1443,7 @@ pub fn run_gui() -> std::process::ExitCode {
}
setup_panic_handler();
easytier::utils::init_crypto_provider();
let mut builder = tauri::Builder::default();
+8 -1
View File
@@ -14,7 +14,14 @@
"icons/icon.icns",
"icons/icon.ico"
],
"createUpdaterArtifacts": false
"createUpdaterArtifacts": false,
"linux": {
"deb": {
"depends": [
"pkexec | policykit-1"
]
}
}
},
"productName": "easytier-gui",
"version": "2.7.0",
+67
View File
@@ -540,4 +540,71 @@ mod tests {
assert_eq!(pair.get_loss_rate(), Some(0.0));
}
#[cfg(feature = "json-rpc")]
#[test]
fn peer_route_pair_json_omits_zero_loss_rate_and_preserves_nonzero_loss_rate() {
let pair = PeerRoutePair {
peer: Some(PeerInfo {
conns: vec![
PeerConnInfo {
conn_id: "zero-loss".to_owned(),
loss_rate: 0.0,
..Default::default()
},
PeerConnInfo {
conn_id: "nonzero-loss".to_owned(),
loss_rate: 0.25,
..Default::default()
},
],
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(pair).unwrap();
let conns = json["peer"]["conns"].as_array().unwrap();
assert!(!conns[0].as_object().unwrap().contains_key("loss_rate"));
assert_eq!(conns[1]["loss_rate"], serde_json::json!(0.25));
}
#[cfg(feature = "json-rpc")]
#[test]
fn peer_route_pair_json_stun_info_uses_enum_names_and_omits_unknown_nat() {
use super::instance::Route;
use crate::proto::common::{NatType, StunInfo};
let known = PeerRoutePair {
route: Some(Route {
stun_info: Some(StunInfo {
udp_nat_type: NatType::FullCone as i32,
tcp_nat_type: NatType::NoPat as i32,
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let unknown = PeerRoutePair {
route: Some(Route {
stun_info: Some(StunInfo::default()),
..Default::default()
}),
..Default::default()
};
let known_json = serde_json::to_value(known).unwrap();
assert_eq!(
known_json["route"]["stun_info"]["udp_nat_type"],
serde_json::json!("FullCone")
);
assert_eq!(
known_json["route"]["stun_info"]["tcp_nat_type"],
serde_json::json!("NoPAT")
);
let unknown_json = serde_json::to_value(unknown).unwrap();
assert_eq!(unknown_json["route"]["stun_info"], serde_json::json!({}));
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use hmac::{Hmac, KeyInit, Mac};
use hmac::{Hmac, Mac};
use prost::Message;
use sha2::Sha256;
#[cfg(feature = "api")]
+9 -3
View File
@@ -16,7 +16,7 @@ tokio-util = { workspace = true, features = ["rt"] }
dashmap.workspace = true
url.workspace = true
async-trait.workspace = true
futures.workspace = true
futures = { workspace = true, features = ["default"] }
prost.workspace = true
maxminddb = "0.32"
@@ -74,8 +74,14 @@ uuid = { workspace = true, features = [
] }
chrono = { workspace = true, features = ["serde"] }
openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps", "reqwest"] }
reqwest = { workspace = true, features = ["json", "rustls"] }
# Keep the bundled reqwest 0.12 client disabled; use the official 0.13 adapter.
openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps"] }
# Pin the adapter until its API stabilizes.
oauth2-reqwest = "=0.1.0-alpha.3"
# Reuse EasyTier's ring provider. reqwest 0.13's rustls feature adds aws-lc,
# making rustls backend selection ambiguous when core/web are built together.
# Initialize the process provider before constructing a reqwest client.
reqwest = { workspace = true, features = ["json", "rustls-no-provider"] }
subtle = "2.6"
mimalloc.workspace = true
@@ -5,7 +5,7 @@ import type { RemoteClient } from '../modules/api'
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
import { latencyMs, lossRate, numericValue, peerConns, udpNatTypeName } from '../modules/statusDisplay';
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, Timeline, Divider, Card, } from 'primevue';
import NetworkChart from './NetworkChart.vue';
import VpnPortalDialog from './VpnPortalDialog.vue';
@@ -144,34 +144,6 @@ interface Chip {
icon: string
}
// udp nat type
enum NatType {
// has NAT; but own a single public IP, port is not changed
Unknown = 0,
OpenInternet = 1,
NoPAT = 2,
FullCone = 3,
Restricted = 4,
PortRestricted = 5,
Symmetric = 6,
SymUdpFirewall = 7,
SymmetricEasyInc = 8,
SymmetricEasyDec = 9,
};
const udpNatTypeStrMap = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
}
const myNodeInfoChips = computed(() => {
if (!props.curNetworkInst)
return []
@@ -246,10 +218,10 @@ const myNodeInfoChips = computed(() => {
} as Chip)
}
const udpNatType: NatType = my_node_info.stun_info?.udp_nat_type
if (udpNatType !== undefined) {
const udpNatType = udpNatTypeName(my_node_info.stun_info)
if (udpNatType) {
chips.push({
label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`,
label: `UDP NAT Type: ${udpNatType}`,
icon: '',
} as Chip)
}
@@ -279,11 +251,7 @@ function rxGlobalSum() {
}
function natType(info: PeerRoutePair): string {
const udpNatType = info.route?.stun_info?.udp_nat_type;
if (udpNatType !== undefined)
return udpNatTypeStrMap[udpNatType as NatType]
return ''
return udpNatTypeName(info.route?.stun_info)
}
function isPublicServerRoute(info: PeerRoutePair): boolean {
@@ -1,4 +1,17 @@
import type { PeerRoutePair } from '../types/network'
import { NatType, type PeerRoutePair, type StunInfo } from '../types/network'
const udpNatTypeStrMap: Record<NatType, string> = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
}
export function numericValue(value: unknown): number | undefined {
if (typeof value === 'number')
@@ -71,7 +84,7 @@ export function latencyMs(info: PeerRoutePair) {
export function lossRate(info: PeerRoutePair) {
for (const conn of defaultConnFirst(info)) {
const loss = numericValue(conn.loss_rate)
const loss = numericValue(conn.loss_rate ?? 0)
if (loss === undefined)
continue
@@ -80,3 +93,12 @@ export function lossRate(info: PeerRoutePair) {
return ''
}
export function udpNatTypeName(stunInfo: StunInfo | undefined) {
if (!stunInfo)
return ''
const value = stunInfo.udp_nat_type ?? NatType.Unknown
const natType = numericValue(value) ?? NatType[value as keyof typeof NatType]
return udpNatTypeStrMap[natType as NatType] ?? udpNatTypeStrMap[NatType.Unknown]
}
@@ -437,9 +437,9 @@ export interface NodeInfo {
}
export interface StunInfo {
udp_nat_type: number
tcp_nat_type: number
last_update_time: number
udp_nat_type?: number | string
tcp_nat_type?: number | string
last_update_time?: number | string
}
export interface Route {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { latencyMs, lossRate } from '../src/modules/statusDisplay'
import { latencyMs, lossRate, udpNatTypeName } from '../src/modules/statusDisplay'
import { ipv4ToString, ipv6ToString } from '../src/modules/utils'
function peerRoutePair(conns: any[]) {
@@ -49,7 +49,7 @@ describe('status display helpers', () => {
expect(ipv6ToString({ part4: 1 } as any)).toBe('::1')
})
it('skips missing latency and loss values', () => {
it('skips missing latency and invalid loss values', () => {
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'valid', stats: { latency_us: '2500' } },
@@ -61,16 +61,25 @@ describe('status display helpers', () => {
]))).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('renders omitted protobuf loss as zero only for an existing connection', () => {
expect(lossRate(peerRoutePair([{ conn_id: 'zero' }]))).toBe('0%')
expect(lossRate(peerRoutePair([{ conn_id: 'zero', loss_rate: 0 }]))).toBe('0%')
expect(lossRate(peerRoutePair([
{ conn_id: 'zero' },
{ conn_id: 'lossy', loss_rate: 0.7 },
]))).toBe('0%')
expect(lossRate(peerRoutePair([]))).toBe('')
expect(lossRate({ route: {} } as any)).toBe('')
})
it('prefers the default connection when its metric is valid', () => {
const defaultConnId = '00000001-0002-0003-0004-000000000005'
const conns = [
@@ -81,4 +90,36 @@ describe('status display helpers', () => {
expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms')
expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%')
})
it('keeps an omitted zero loss on the default connection', () => {
const defaultConnId = '00000001-0002-0003-0004-000000000005'
expect(lossRate(peerRoutePairWithDefaultConn([
{ conn_id: 'fallback', loss_rate: 0.25 },
{ conn_id: defaultConnId },
], defaultConnId))).toBe('0%')
})
it.each([
[0, 'Unknown', 'Unknown'],
[1, 'OpenInternet', 'Open Internet'],
[2, 'NoPAT', 'No PAT'],
[3, 'FullCone', 'Full Cone'],
[4, 'Restricted', 'Restricted'],
[5, 'PortRestricted', 'Port Restricted'],
[6, 'Symmetric', 'Symmetric'],
[7, 'SymUdpFirewall', 'Symmetric UDP Firewall'],
[8, 'SymmetricEasyInc', 'Symmetric Easy Inc'],
[9, 'SymmetricEasyDec', 'Symmetric Easy Dec'],
])('renders numeric and protobuf enum NAT type %s', (value, name, label) => {
expect(udpNatTypeName({ udp_nat_type: value })).toBe(label)
expect(udpNatTypeName({ udp_nat_type: name })).toBe(label)
expect(udpNatTypeName({ udp_nat_type: String(value) })).toBe(label)
})
it('distinguishes missing STUN data from default or unrecognized NAT types', () => {
expect(udpNatTypeName(undefined)).toBe('')
expect(udpNatTypeName({})).toBe('Unknown')
expect(udpNatTypeName({ udp_nat_type: 100 })).toBe('Unknown')
expect(udpNatTypeName({ udp_nat_type: 'FutureNatType' })).toBe('Unknown')
})
})
@@ -0,0 +1,87 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Status from '../src/components/Status.vue'
import type { NetworkInstance } from '../src/types/network'
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
const wrappers: VueWrapper[] = []
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
})
async function render(localStun: unknown, peerStun: unknown, lossRate?: number) {
const instance = {
instance_id: '12345678-9abc-def0-fedc-ba9876543210',
running: true,
detail: {
my_node_info: {
hostname: 'local-node',
version: 'test',
peer_id: 1,
stun_info: localStun,
},
peer_route_pairs: [{
route: {
hostname: 'remote-node',
version: 'test',
cost: 1,
stun_info: peerStun,
},
peer: {
conns: [{
conn_id: 'connection',
...(lossRate === undefined ? {} : { loss_rate: lossRate }),
}],
},
}],
},
} as unknown as NetworkInstance
const wrapper = mount(Status, {
props: {
curNetworkInst: instance,
api: { get_network_config: vi.fn(async () => ({})) } as any,
},
global: {
plugins: [PrimeVue],
directives: { tooltip: () => {} },
stubs: { HumanEvent: true, NetworkChart: true, VpnPortalDialog: true },
},
})
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
describe('status protobuf JSON rendering', () => {
it('renders enum names and omitted zero loss in the real peer table', async () => {
const wrapper = await render({ udp_nat_type: 'FullCone' }, { udp_nat_type: 'PortRestricted' })
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('Full Cone')
expect(rows[0].text()).not.toContain('0%')
expect(rows[1].text()).toContain('Port Restricted')
expect(rows[1].text()).toContain('0%')
await wrapper.findAll('button').find(button => button.text() === 'show_node_details')!.trigger('click')
expect(wrapper.text()).toContain('UDP NAT Type: Full Cone')
expect(wrapper.text()).not.toContain('UDP NAT Type: undefined')
})
it('renders omitted enum defaults as Unknown but leaves missing STUN data blank', async () => {
const wrapper = await render({}, undefined, 0.25)
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('Unknown')
expect(rows[1].text()).not.toContain('Unknown')
expect(rows[1].text()).toContain('25%')
expect(wrapper.text()).toContain('UDP NAT Type: Unknown')
})
it('preserves numeric NAT values from older backends', async () => {
const wrapper = await render({ udp_nat_type: 1 }, { udp_nat_type: 6 }, 0.5)
const rows = wrapper.findAll('tbody tr')
expect(rows[0].text()).toContain('Open Internet')
expect(rows[1].text()).toContain('Symmetric')
expect(rows[1].text()).toContain('50%')
})
})
@@ -1499,7 +1499,7 @@ const switchTab = async (tab: string) => {
{{ data.conn.stats ? latencyLabel(data.conn.stats.latency_us ? data.conn.stats.latency_us / 1000 : undefined) : '—' }}
</template></Column>
<Column :header="t('web.network_detail.loss_rate')"><template #body="{ data }">
{{ data.conn.loss_rate != null ? (data.conn.loss_rate * 100).toFixed(1) + '%' : '—' }}
{{ ((data.conn.loss_rate ?? 0) * 100).toFixed(1) + '%' }}
</template></Column>
<Column :header="t('web.network_detail.traffic')"><template #body="{ data }">
<span class="font-mono text-xs">↓{{ ((data.conn.stats?.rx_bytes ?? 0) / 1024).toFixed(0) }}K ↑{{ ((data.conn.stats?.tx_bytes ?? 0) / 1024).toFixed(0) }}K</span>
@@ -562,14 +562,17 @@ test('node detail groups populated peers, routes, connections and ACL stats', as
const { page } = await open(t, `/h/networks/${id(100)}`, { viewport: { width: 390, height: 844 }, colorScheme: 'dark' }, state => {
state.networks[0].network_id = id(100);
state.nodeRoutes = [{ peer_id: 2, hostname: 'Build server', ipv4_addr: { address: { addr: 175005442 }, network_length: 24 }, cost: 1, path_latency: 8, proxy_cidrs: [], version: '2.4.5', next_hop_peer_id: 2 }];
state.nodePeers = [{ peer_id: 2, conns: [{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 }, loss_rate: 0 }] }];
state.nodePeers = [{ peer_id: 2, conns: [
{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 } },
{ conn_id: 'conn-2', tunnel: { tunnel_type: 'udp', remote_addr: { url: 'udp://192.0.2.11:11010' } }, stats: { latency_us: 9000 }, loss_rate: 0.125 },
] }];
state.nodeAclStats = [{ rule: { name: 'Allow build server' }, stat: { packet_count: 42, byte_count: 4096 } }];
});
await page.locator('.mobile-list').getByRole('button', { name: 'Node Detail' }).click();
const drawer = page.locator('.console-node-drawer');
await drawer.locator('.node-drawer-mobile-peer').getByText('Build server').waitFor();
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '1']);
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '2']);
assert.equal(await drawer.getByRole('tab').count(), 2);
const drawerWidth = await drawer.evaluate(el => ({ drawer: el.getBoundingClientRect().width, viewport: innerWidth }));
assert.ok(drawerWidth.drawer <= drawerWidth.viewport + 1, JSON.stringify(drawerWidth));
@@ -580,6 +583,8 @@ test('node detail groups populated peers, routes, connections and ACL stats', as
await drawer.locator('details').filter({ hasText: 'Routes' }).locator('summary').click();
await drawer.getByText('10.110.95.2/24').first().waitFor();
await drawer.locator('details').filter({ hasText: 'Connections' }).locator('summary').click();
await drawer.getByRole('cell', { name: '0.0%', exact: true }).waitFor();
await drawer.getByRole('cell', { name: '12.5%', exact: true }).waitFor();
await drawer.locator('details').filter({ hasText: 'ACL Stats' }).locator('summary').click();
await drawer.getByRole('cell', { name: 'Allow build server' }).waitFor();
});
+1
View File
@@ -303,6 +303,7 @@ async fn main() {
let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
rust_i18n::set_locale(&locale);
setup_panic_handler();
easytier::utils::init_crypto_provider();
let cli = Cli::parse();
log::init_with_default_console_targets(&cli, false, &["CORE", "easytier_web"]).unwrap();
+125 -6
View File
@@ -1,4 +1,4 @@
use openidconnect::reqwest;
use oauth2_reqwest::ReqwestClient;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -180,7 +180,7 @@ pub struct OidcConfig {
pub scopes: Vec<String>,
pub pkce_enabled: bool,
pub frontend_base_url: Option<String>,
pub http_client: Option<reqwest::Client>,
pub http_client: Option<ReqwestClient>,
cached_client: Option<Arc<ConfiguredAppClient>>,
}
@@ -224,10 +224,14 @@ impl OidcConfig {
if oidc_username_claim.trim().is_empty() {
return Err(anyhow::anyhow!("--oidc-username-claim cannot be empty"));
}
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()?;
// OIDC configuration can also be created outside the web entry point.
easytier::utils::init_crypto_provider();
let http_client = ReqwestClient::from(
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()?,
);
let issuer_url = oidc_issuer_url.ok_or_else(|| {
anyhow::anyhow!("--oidc-issuer-url is required when using OIDC authentication")
@@ -697,6 +701,121 @@ mod route {
mod tests {
use super::*;
#[tokio::test]
async fn reqwest_adapter_supports_discovery_and_token_exchange() {
use axum::{
Form, Json,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect},
routing::post,
};
use openidconnect::{AuthorizationCode, OAuth2TokenResponse, RequestTokenError};
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio_util::task::AbortOnDropHandle;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let metadata = json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/authorize"),
"token_endpoint": format!("{issuer}/token"),
"jwks_uri": format!("{issuer}/jwks"),
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
});
let redirect_hits = Arc::new(AtomicUsize::new(0));
let hits = redirect_hits.clone();
let app = Router::new()
.route(
"/.well-known/openid-configuration",
get(move || async move { Json(metadata) }),
)
.route("/jwks", get(|| async { Json(json!({ "keys": [] })) }))
.route(
"/token",
post(
|headers: HeaderMap, Form(form): Form<HashMap<String, String>>| async move {
assert_eq!(
headers["authorization"],
"Basic dGVzdC1jbGllbnQ6dGVzdC1zZWNyZXQ="
);
assert_eq!(form["grant_type"], "authorization_code");
assert_eq!(form["redirect_uri"], "http://localhost/callback");
match form["code"].as_str() {
"valid" => Json(json!({
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 3600,
}))
.into_response(),
"redirect" => Redirect::temporary("/unexpected").into_response(),
_ => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "invalid_grant" })),
)
.into_response(),
}
},
),
)
.route(
"/unexpected",
post(move || async move {
hits.fetch_add(1, Ordering::SeqCst);
Json(json!({ "access_token": "unexpected", "token_type": "Bearer" }))
}),
);
let _server = AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
}));
let config = OidcConfig::from_params(OidcOptions {
oidc_issuer_url: Some(issuer),
oidc_client_id: Some("test-client".to_owned()),
oidc_client_secret: Some("test-secret".to_owned()),
oidc_username_claim: "preferred_username".to_owned(),
oidc_scopes: vec!["openid".to_owned()],
oidc_redirect_url: Some("http://localhost/callback".to_owned()),
oidc_disable_pkce: false,
oidc_frontend_base_url: None,
})
.await
.unwrap();
let client = config.client().unwrap();
let http_client = config.http_client.as_ref().unwrap();
let token = client
.exchange_code(AuthorizationCode::new("valid".to_owned()))
.unwrap()
.request_async(http_client)
.await
.unwrap();
assert_eq!(token.access_token().secret(), "test-access-token");
assert_eq!(token.expires_in(), Some(Duration::from_secs(3600)));
let error = client
.exchange_code(AuthorizationCode::new("invalid".to_owned()))
.unwrap()
.request_async(http_client)
.await
.unwrap_err();
assert!(
matches!(error, RequestTokenError::ServerResponse(ref response)
if response.error() == &CoreErrorResponseType::InvalidGrant)
);
assert!(
client
.exchange_code(AuthorizationCode::new("redirect".to_owned()))
.unwrap()
.request_async(http_client)
.await
.is_err()
);
assert_eq!(redirect_hits.load(Ordering::SeqCst), 0);
}
#[test]
fn test_dot_path_to_json_pointer() {
use serde_json::json;
+4
View File
@@ -281,6 +281,10 @@ impl WebhookConfig {
web_instance_id: Option<String>,
web_instance_api_base_url: Option<String>,
) -> Self {
// This constructor is also used without main (for example in tests).
// reqwest's rustls-no-provider needs a process default even if ring
// is the only compiled backend.
easytier::utils::init_crypto_provider();
WebhookConfig {
webhook_url,
webhook_secret,
+4 -2
View File
@@ -61,7 +61,7 @@ strum = { workspace = true, features = ["derive"] }
gethostname.workspace = true
futures = { workspace = true, features = ["bilock", "unstable"] }
futures = { workspace = true, features = ["default", "bilock", "unstable"] }
tokio = { workspace = true, features = [
"fs",
@@ -178,7 +178,8 @@ network-interface = "2.0.5"
# for wireguard
boringtun = { package = "boringtun-easytier", version = "0.6.1", optional = true }
hkdf = { version = "0.13", optional = true }
# Share the workspace HMAC/SHA2 digest generation for WireGuard key derivation.
hkdf = { version = "0.12.4", optional = true }
sha2 = { workspace = true, optional = true }
# for cli
@@ -415,6 +416,7 @@ upnp = [
]
endpoint-discovery = [
"dns-resolver",
"dep:rustls",
"easytier-core/endpoint-discovery",
]
dhcp-ipv4 = ["easytier-core/dhcp-ipv4"]
+3
View File
@@ -13,6 +13,9 @@ core_clap:
the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. by default it is loaded from persisted local state; on first start it may be migrated from system information or generated, then remains fixed.
zh-CN: |+
Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从本地持久化状态读取;首次启动时可能基于系统信息迁移或生成,之后保持固定不变。
state_dir:
en: "directory for persistent local state, currently the Web config server machine ID. Uses the platform default when unset; ignored when --machine-id is specified."
zh-CN: "本地持久化状态目录,目前用于保存 Web 配置服务器的机器 ID。未指定时使用平台默认目录;指定 --machine-id 时不使用此目录。"
config_file:
en: "path to the config file, NOTE: the options set by cmdline args will override options in config file"
zh-CN: "配置文件路径,注意:命令行中的配置的选项会覆盖配置文件中的选项"
+9 -1
View File
@@ -100,6 +100,13 @@ struct Cli {
)]
machine_id: Option<String>,
#[arg(
long,
env = "ET_STATE_DIR",
help = t!("core_clap.state_dir").to_string()
)]
state_dir: Option<PathBuf>,
#[arg(
short,
long,
@@ -1536,7 +1543,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
config_server_url_s,
crate::common::MachineIdOptions {
explicit_machine_id: cli.machine_id.clone(),
state_dir: None,
state_dir: cli.state_dir.clone(),
},
cli.network_options.hostname.clone(),
cli.network_options.secure_mode.unwrap_or(false),
@@ -1733,6 +1740,7 @@ pub async fn main() -> ExitCode {
let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
rust_i18n::set_locale(&locale);
setup_panic_handler();
crate::utils::init_crypto_provider();
#[cfg(target_os = "windows")]
match windows_service::service_dispatcher::start(String::new(), ffi_service_main) {
+16 -15
View File
@@ -182,17 +182,15 @@ impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
}
}
fn init_crypto_provider() {
let _ =
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider());
}
fn get_insecure_tls_client_config() -> rustls::ClientConfig {
init_crypto_provider();
let provider = rustls::crypto::CryptoProvider::get_default().unwrap();
let mut config = rustls::ClientConfig::builder()
// Library callers may not have initialized a process-wide provider.
// Use the same explicit backend for TLS and signature verification.
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.expect("ring supports the default TLS protocol versions")
.dangerous()
.with_custom_certificate_verifier(SkipServerVerification::new(provider.clone()))
.with_custom_certificate_verifier(SkipServerVerification::new(provider))
.with_no_client_auth();
config.enable_sni = true;
config.enable_early_data = false;
@@ -220,12 +218,16 @@ where
let peer_addr = stream.peer_addr()?;
let mut remote_url = socket_url(local_url.scheme(), peer_addr);
let stream = if is_wss(&local_url)? {
init_crypto_provider();
let (certificates, private_key) = get_insecure_tls_cert();
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certificates, private_key)
.with_context(|| "Failed to create server config")?;
// Do not rely on another tunnel initializing the global provider.
let config = rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.with_context(|| "Failed to select TLS protocol versions")?
.with_no_client_auth()
.with_single_cert(certificates, private_key)
.with_context(|| "Failed to create server config")?;
Either::Left(TlsAcceptor::from(Arc::new(config)).accept(stream).await?)
} else {
Either::Right(stream)
@@ -390,7 +392,6 @@ where
let client = ClientBuilder::from_uri(http::Uri::try_from(remote_url.to_string()).unwrap())
.max_headers(128);
let stream: MaybeTlsStream<S> = if is_wss {
init_crypto_provider();
let tls = tokio_rustls::TlsConnector::from(Arc::new(get_insecure_tls_client_config()));
let sni = remote_url.domain().unwrap_or("localhost").to_owned();
let server_name = rustls::pki_types::ServerName::try_from(sni)
+48 -14
View File
@@ -14,10 +14,17 @@ pub struct BufMargins {
impl BufMargins {
#[inline(always)]
pub fn size(&self) -> usize {
self.header + self.trailer
self.header.checked_add(self.trailer).unwrap()
}
}
/// A reusable packet buffer pool.
///
/// # Initialization contract
///
/// Buffers may contain uninitialized bytes in their [`BufMargins::header`] and
/// [`BufMargins::trailer`] regions. These regions must be fully initialized
/// before they are read or otherwise consumed.
#[derive(Debug)]
pub struct BufPool {
pool: BytesMut,
@@ -40,6 +47,10 @@ impl BufPool {
}
}
/// Returns the accumulated buffer.
///
/// The caller must ensure all reserved header and trailer regions have been
/// initialized before reading or consuming the returned buffer.
#[inline(always)]
pub fn split(&mut self) -> BytesMut {
self.pool.split()
@@ -47,8 +58,10 @@ impl BufPool {
#[inline]
pub fn write(&mut self, chunk: &[u8], margins: BufMargins) {
let len = margins.size() + chunk.len();
let len = margins.size().checked_add(chunk.len()).unwrap();
self.reserve(len);
// Header and trailer are intentionally left uninitialized. Users of this
// buffer must initialize both regions before reading or consuming it.
unsafe {
copy_nonoverlapping(
chunk.as_ptr(),
@@ -59,6 +72,10 @@ impl BufPool {
}
}
/// Writes `chunk` with the requested margins and returns the resulting buffer.
///
/// The header and trailer regions are left uninitialized and must be filled
/// before the returned buffer is read or consumed.
#[inline(always)]
pub fn buf(&mut self, chunk: &[u8], margins: BufMargins) -> BytesMut {
self.write(chunk, margins);
@@ -77,6 +94,12 @@ impl BufPool {
}
}
/// A writer into a [`BufPool`].
///
/// # Initialization contract
///
/// Committing data does not initialize the reserved header or trailer regions.
/// They must be fully initialized before the resulting buffer is read or consumed.
#[derive(Debug)]
pub struct BufPoolWriter<'t> {
pool: &'t mut BufPool,
@@ -87,32 +110,43 @@ pub struct BufPoolWriter<'t> {
impl<'t> BufPoolWriter<'t> {
#[inline(always)]
pub fn reserve(&mut self, additional: usize) {
if self.capacity < additional {
self.pool.reserve(additional);
self.capacity += additional;
}
let capacity = self.capacity.checked_add(additional).unwrap();
self.pool.reserve(capacity);
self.capacity = capacity;
}
/// Returns the accumulated buffer.
///
/// Reserved header and trailer regions must be initialized before the returned
/// buffer is read or consumed.
#[inline(always)]
pub fn split(&mut self) -> BytesMut {
self.pool.split()
}
#[inline(always)]
pub fn remaining(&self) -> usize {
self.capacity.saturating_sub(self.margins.size())
}
#[inline(always)]
pub fn as_slice(&mut self) -> &mut [MaybeUninit<u8>] {
unsafe {
self.pool
.pool
.spare_capacity_mut()
.get_unchecked_mut(self.margins.header..self.capacity - self.margins.trailer)
let remaining = self.remaining();
if remaining == 0 {
&mut []
} else {
&mut self.pool.pool.spare_capacity_mut()
[self.margins.header..self.margins.header + remaining]
}
}
#[inline(always)]
pub fn commit(&mut self, written: usize) {
let len = self.margins.size() + written;
let len = self.margins.size().checked_add(written).unwrap();
assert!(self.capacity >= len);
self.capacity -= len;
// `commit` marks the margins as part of the buffer without initializing them.
// They must be initialized before the resulting buffer is consumed.
unsafe {
self.pool.pool.advance_mut(len);
}
@@ -141,8 +175,8 @@ impl<const SIZE: usize> FixedBufPool<SIZE> {
#[derive(Debug, Deref, DerefMut, AsRef, AsMut)]
pub struct FixedBufGuard<'p, const SIZE: usize> {
pool: &'p FixedBufPool<SIZE>,
#[deref]
#[deref_mut]
#[deref(forward)]
#[deref_mut(forward)]
#[as_ref([u8])]
#[as_mut([u8])]
buf: Vec<u8>,
+15
View File
@@ -9,6 +9,21 @@ use std::sync::{Arc, Weak};
#[cfg(feature = "management")]
pub type PeerRoutePair = crate::proto::api::instance::PeerRoutePair;
/// Select the process-wide TLS backend before starting application services.
pub fn init_crypto_provider() {
#[cfg(any(
feature = "endpoint-discovery",
feature = "quic",
feature = "websocket"
))]
{
// Cargo features are additive: another dependency can enable aws-lc
// alongside ring, making rustls's automatic selection panic (#2607).
// Keep an existing provider chosen by an embedding application.
let _ = rustls::crypto::ring::default_provider().install_default();
}
}
pub fn check_tcp_available(port: u16) -> bool {
let s = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
TcpListener::bind(s).is_ok()
+9
View File
@@ -395,6 +395,15 @@ mod tests {
fn named_wireguard_keys_are_stable_and_client_scoped() {
let master = [7; 32];
let client = derive_named_key(&master, b"wireguard-client", "laptop").unwrap();
// Pin the derived key across crypto dependency upgrades/downgrades.
// Independently calculated with RFC 5869 HKDF-SHA256.
assert_eq!(
client,
[
5, 98, 244, 32, 245, 111, 41, 24, 163, 149, 201, 218, 22, 228, 8, 224, 134, 16,
173, 29, 62, 138, 202, 41, 172, 230, 189, 237, 207, 100, 51, 236,
]
);
assert_eq!(
client,
derive_named_key(&master, b"wireguard-client", "laptop").unwrap()