From 5cd0a3e846ef44e5c68b94fad2a571f346901f70 Mon Sep 17 00:00:00 2001 From: Debugger Chen <278290457@qq.com> Date: Tue, 21 Apr 2026 17:19:04 +0800 Subject: [PATCH] feat: add upnp support (#1449) --- .github/workflows/test.yml | 4 + Cargo.lock | 514 ++++- easytier/Cargo.toml | 2 + easytier/locales/app.yml | 3 + easytier/src/common/config.rs | 1 + easytier/src/common/global_ctx.rs | 5 + easytier/src/common/mod.rs | 1 + easytier/src/common/stun.rs | 21 +- easytier/src/common/upnp.rs | 767 +++++++ easytier/src/connector/direct.rs | 151 +- easytier/src/connector/tcp_hole_punch.rs | 7 + .../src/connector/udp_hole_punch/common.rs | 270 ++- easytier/src/connector/udp_hole_punch/cone.rs | 33 +- easytier/src/connector/udp_hole_punch/mod.rs | 10 +- .../connector/udp_hole_punch/sym_to_cone.rs | 5 +- easytier/src/core.rs | 14 +- easytier/src/instance_manager.rs | 15 + easytier/src/launcher.rs | 6 + easytier/src/peers/peer_rpc_service.rs | 20 +- easytier/src/proto/api_manage.proto | 1 + easytier/src/proto/common.proto | 1 + easytier/src/proto/peer_rpc.proto | 5 +- easytier/src/tests/mod.rs | 3 + easytier/src/tests/upnp_test.rs | 1952 +++++++++++++++++ easytier/src/tunnel/packet_def.rs | 11 +- easytier/src/tunnel/udp.rs | 120 +- 26 files changed, 3707 insertions(+), 235 deletions(-) create mode 100644 easytier/src/common/upnp.rs create mode 100644 easytier/src/tests/upnp_test.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82e4a207..599f11ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -128,6 +128,10 @@ jobs: - name: Setup tools for test run: sudo apt install bridge-utils + - name: Setup upnpd for test + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables - name: Setup system for test run: | diff --git a/Cargo.lock b/Cargo.lock index 63cb89de..067526d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,7 +32,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.13", ] [[package]] @@ -237,7 +237,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.13", "password-hash", ] @@ -353,7 +353,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -403,7 +403,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -420,7 +420,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -476,6 +476,18 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "auto_impl" version = "1.2.1" @@ -484,7 +496,7 @@ checksum = "e12882f59de5360c748c4cbf569a042d5fb0eb515f7bea9c1f470b47f6ffbd73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -669,7 +681,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -680,7 +692,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -765,7 +777,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -857,7 +869,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -906,7 +918,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1184,7 +1196,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.13", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1194,7 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1299,7 +1322,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1527,6 +1550,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.2.1" @@ -1666,7 +1698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1676,7 +1708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1711,7 +1743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.13", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1728,7 +1760,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1768,7 +1800,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1781,7 +1813,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1792,7 +1824,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1803,7 +1835,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1883,7 +1915,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1903,7 +1935,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1946,7 +1978,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1967,7 +1999,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1977,7 +2009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -1990,7 +2022,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -2012,7 +2044,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.87", + "syn 2.0.117", "unicode-xid", ] @@ -2089,7 +2121,18 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", ] [[package]] @@ -2112,7 +2155,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -2240,6 +2283,7 @@ dependencies = [ "humansize", "humantime-serde", "idna 1.0.3", + "igd-next", "indoc", "itertools 0.14.0", "kcp-sys", @@ -2247,6 +2291,7 @@ dependencies = [ "maplit", "mimalloc", "multimap", + "natpmp", "netlink-packet-core", "netlink-packet-route 0.21.0", "netlink-packet-utils", @@ -2676,7 +2721,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -2697,7 +2742,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -2827,7 +2872,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -2950,7 +2995,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -3089,7 +3134,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -3313,11 +3358,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.2.0", "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -3377,7 +3436,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -3414,7 +3473,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -3528,7 +3587,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -4121,9 +4180,15 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -4161,6 +4226,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "ignore" version = "0.4.22" @@ -4271,7 +4356,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -4578,6 +4663,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libappindicator" version = "0.9.0" @@ -4826,7 +4917,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -4986,7 +5077,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -5080,6 +5171,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "natpmp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77366fa8ce34e2e1322dd97da65f11a62f451bd3daae8be6993c00800f61dd07" +dependencies = [ + "async-trait", + "cc", + "netdev", + "tokio", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5110,6 +5213,23 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "netdev" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f901362e84cd407be6f8cd9d3a46bccf09136b095792785401ea7d283c79b91d" +dependencies = [ + "dlopen2 0.5.0", + "ipnet", + "libc", + "netlink-packet-core", + "netlink-packet-route 0.17.1", + "netlink-sys", + "once_cell", + "system-configuration", + "windows-sys 0.52.0", +] + [[package]] name = "netlink-packet-core" version = "0.7.0" @@ -5447,7 +5567,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -5758,7 +5878,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -5879,7 +5999,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6191,7 +6311,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6238,7 +6358,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6351,7 +6471,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6443,7 +6563,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.13", "opaque-debug", "universal-hash", ] @@ -6455,7 +6575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.13", "opaque-debug", "universal-hash", ] @@ -6531,7 +6651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6614,7 +6734,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6640,7 +6760,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "version_check", "yansi", ] @@ -6671,7 +6791,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.87", + "syn 2.0.117", "tempfile", ] @@ -6685,7 +6805,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6718,7 +6838,7 @@ checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -6909,6 +7029,12 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -6960,6 +7086,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -7017,6 +7154,12 @@ dependencies = [ "getrandom 0.3.2", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.2.2" @@ -7136,7 +7279,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -7415,7 +7558,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.87", + "syn 2.0.117", "unicode-ident", ] @@ -7439,7 +7582,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.87", + "syn 2.0.117", "walkdir", ] @@ -7482,7 +7625,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -7754,7 +7897,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -7785,7 +7928,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -7845,7 +7988,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.87", + "syn 2.0.117", "unicode-ident", ] @@ -7908,7 +8051,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "thiserror 2.0.11", ] @@ -7932,7 +8075,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8067,7 +8210,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8078,7 +8221,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8135,7 +8278,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8196,7 +8339,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8251,7 +8394,7 @@ checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8273,7 +8416,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8308,7 +8451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.13", "digest", ] @@ -8319,7 +8462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.13", "digest", ] @@ -8646,7 +8789,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8669,7 +8812,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.87", + "syn 2.0.117", "tokio", "url", ] @@ -8869,7 +9012,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -8917,9 +9060,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.87" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -8943,7 +9086,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -9040,7 +9183,7 @@ dependencies = [ "core-graphics", "crossbeam-channel", "dispatch2", - "dlopen2", + "dlopen2 0.8.2", "dpi", "gdkwayland-sys", "gdkx11-sys", @@ -9074,7 +9217,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -9181,7 +9324,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.87", + "syn 2.0.117", "tauri-utils", "thiserror 2.0.11", "time", @@ -9199,7 +9342,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] @@ -9491,7 +9634,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -9502,7 +9645,7 @@ checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -9655,7 +9798,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -9901,7 +10044,7 @@ dependencies = [ "proc-macro2", "prost-build", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -10076,7 +10219,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -10257,7 +10400,7 @@ checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -10467,7 +10610,7 @@ checksum = "26b682e8c381995ea03130e381928e0e005b7c9eb483c6c8682f50e07b33c2b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -10497,7 +10640,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -10584,6 +10727,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasite" version = "0.1.0" @@ -10633,7 +10794,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -10646,6 +10807,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -10659,6 +10842,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.8.0", + "hashbrown 0.15.3", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -10861,7 +11056,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11109,7 +11304,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11120,7 +11315,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11131,7 +11326,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11142,7 +11337,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11622,6 +11817,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -11631,6 +11852,74 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.8.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wl-clipboard-rs" version = "0.9.3" @@ -11771,6 +12060,15 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4e2e2f7cba5a093896c1e150fbfe177d1883e7448200efb81d40b9d339ef26" +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yansi" version = "1.0.1" @@ -11806,7 +12104,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "synstructure", ] @@ -11853,7 +12151,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -11897,7 +12195,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11908,7 +12206,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11928,7 +12226,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "synstructure", ] @@ -11949,7 +12247,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -11971,7 +12269,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", ] [[package]] @@ -12090,7 +12388,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.117", "zvariant_utils", ] @@ -12103,6 +12401,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.87", + "syn 2.0.117", "winnow 0.7.15", ] diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 41801677..70127b62 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -252,6 +252,8 @@ shellexpand = "3.1.1" # for fake tcp flume = { version = "0.12", optional = true } +igd-next = { version = "0.17.0", features = ["aio_tokio"] } +natpmp = "0.5.0" [target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies] machine-uid = "0.5.3" diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index bc6cb7d3..6139e56c 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -172,6 +172,9 @@ core_clap: disable_sym_hole_punching: en: "if true, disable udp nat hole punching for symmetric nat (NAT4), which is based on birthday attack and may be blocked by ISP." zh-CN: "如果为true,则禁用基于生日攻击的对称NAT (NAT4) UDP 打洞功能,该打洞方式可能会被运营商封锁" + disable_upnp: + en: "disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default" + zh-CN: "禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启" relay_all_peer_rpc: en: "relay all peer rpc packets, even if the peer is not in the relay network whitelist. this can help peers not in relay network whitelist to establish p2p connection." zh-CN: "转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立P2P连接。" diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index 173ead6f..3969dd0a 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -69,6 +69,7 @@ pub fn gen_default_flags() -> Flags { quic_listen_port: u32::MAX, need_p2p: false, instance_recv_bps_limit: u64::MAX, + disable_upnp: false, } } diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index b6af900b..a0b0e6ad 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -53,6 +53,11 @@ pub enum GlobalCtxEvent { ListenerAcceptFailed(url::Url, String), // (url, error message) ConnectionAccepted(String, String), // (local url, remote url) ConnectionError(String, String, String), // (local url, remote url, error message) + ListenerPortMappingEstablished { + local_listener: url::Url, + mapped_listener: url::Url, + backend: String, + }, Connecting(url::Url), ConnectError(String, String, String), // (dst, ip version, error message) diff --git a/easytier/src/common/mod.rs b/easytier/src/common/mod.rs index 1b8e303c..8d066dde 100644 --- a/easytier/src/common/mod.rs +++ b/easytier/src/common/mod.rs @@ -31,6 +31,7 @@ pub mod stun; pub mod stun_codec_ext; pub mod token_bucket; pub mod tracing_rolling_appender; +pub mod upnp; pub fn get_logger_timer( format: F, diff --git a/easytier/src/common/stun.rs b/easytier/src/common/stun.rs index 94a36479..a4a8a397 100644 --- a/easytier/src/common/stun.rs +++ b/easytier/src/common/stun.rs @@ -276,7 +276,6 @@ impl StunClient { let stun_host = self.stun_server; // repeat req in case of packet loss let mut tids = vec![]; - for _ in 0..self.req_repeat { let tid = rand::random::(); // let tid = 1; @@ -912,6 +911,10 @@ impl TcpNatTypeDetector { pub trait StunInfoCollectorTrait: Send + Sync { fn get_stun_info(&self) -> StunInfo; async fn get_udp_port_mapping(&self, local_port: u16) -> Result; + async fn get_udp_port_mapping_with_socket( + &self, + udp: Arc, + ) -> Result; async fn get_tcp_port_mapping(&self, local_port: u16) -> Result; } @@ -975,6 +978,14 @@ impl StunInfoCollectorTrait for StunInfoCollector { } async fn get_udp_port_mapping(&self, local_port: u16) -> Result { + let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?); + self.get_udp_port_mapping_with_socket(udp).await + } + + async fn get_udp_port_mapping_with_socket( + &self, + udp: Arc, + ) -> Result { self.start_stun_routine(); let mut stun_servers = self @@ -1000,7 +1011,6 @@ impl StunInfoCollectorTrait for StunInfoCollector { return Err(Error::NotFound); } - let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?); let mut client_builder = StunClientBuilder::new(udp.clone()); for server in stun_servers.iter() { @@ -1316,6 +1326,13 @@ impl StunInfoCollectorTrait for MockStunInfoCollector { Ok(format!("127.0.0.1:{}", port).parse().unwrap()) } + async fn get_udp_port_mapping_with_socket( + &self, + udp: Arc, + ) -> Result { + self.get_udp_port_mapping(udp.local_addr()?.port()).await + } + async fn get_tcp_port_mapping(&self, mut port: u16) -> Result { if port == 0 { port = 40144; diff --git a/easytier/src/common/upnp.rs b/easytier/src/common/upnp.rs new file mode 100644 index 00000000..61ec357f --- /dev/null +++ b/easytier/src/common/upnp.rs @@ -0,0 +1,767 @@ +use std::{ + fmt, + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context, anyhow, bail}; +use igd_next::{ + AddAnyPortError, PortMappingProtocol, SearchOptions, + aio::{ + Gateway, + tokio::{Tokio, search_gateway}, + }, +}; +use natpmp::{ + Protocol as NatPmpProtocol, Response as NatPmpResponse, new_tokio_natpmp, new_tokio_natpmp_with, +}; +use tokio::{net::UdpSocket, sync::oneshot}; + +use super::{ + global_ctx::{ArcGlobalCtx, GlobalCtxEvent}, + stun::StunInfoCollectorTrait as _, +}; +use crate::tunnel::build_url_from_socket_addr; + +const UPNP_SEARCH_TIMEOUT: Duration = Duration::from_secs(1); +const UPNP_SEARCH_RESPONSE_TIMEOUT: Duration = Duration::from_millis(300); +const NAT_PMP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(1); +const UPNP_LEASE_DURATION_SECS: u32 = 300; +const UPNP_RENEW_INTERVAL: Duration = Duration::from_secs(240); +const UPNP_DESCRIPTION: &str = "EasyTier udp hole punch"; +const PORT_MAPPING_BACKEND_NAT_PMP: &str = "nat-pmp"; +const PORT_MAPPING_BACKEND_IGD: &str = "igd"; + +type TokioGateway = Gateway; + +enum PortMappingBackend { + NatPmp { gateway: Ipv4Addr }, + Igd { gateway: TokioGateway }, +} + +impl PortMappingBackend { + fn name(&self) -> &'static str { + match self { + Self::NatPmp { .. } => PORT_MAPPING_BACKEND_NAT_PMP, + Self::Igd { .. } => PORT_MAPPING_BACKEND_IGD, + } + } +} + +struct ActiveUdpPortMapping { + backend: PortMappingBackend, + local_listener: url::Url, + local_addr: SocketAddr, + gateway_external_port: u16, +} + +impl ActiveUdpPortMapping { + async fn discover_nat_pmp_gateway( + local_listener: &url::Url, + ) -> anyhow::Result<(Ipv4Addr, SocketAddr)> { + let client = new_tokio_natpmp().await.context("create nat-pmp client")?; + let gateway = *client.gateway(); + let gateway_addr = SocketAddr::V4(SocketAddrV4::new(gateway, natpmp::NATPMP_PORT)); + let local_addr = resolve_internal_addr(gateway_addr, local_listener).await?; + Ok((gateway, local_addr)) + } + + async fn establish_via_nat_pmp( + local_listener: &url::Url, + gateway: Ipv4Addr, + local_addr: SocketAddr, + ) -> anyhow::Result { + let gateway_external_port = + add_udp_mapping_port_nat_pmp(gateway, local_addr, local_listener) + .await + .with_context(|| { + format!("map udp socket for {local_listener} via nat-pmp gateway {gateway}") + })?; + + Ok(Self { + backend: PortMappingBackend::NatPmp { gateway }, + local_listener: local_listener.clone(), + local_addr, + gateway_external_port, + }) + } + + async fn discover_igd_gateway( + global_ctx: &ArcGlobalCtx, + local_listener: &url::Url, + ) -> anyhow::Result<(TokioGateway, SocketAddr)> { + let _g = global_ctx.net_ns.guard(); + let gateway = search_gateway(SearchOptions { + timeout: Some(UPNP_SEARCH_TIMEOUT), + single_search_timeout: Some(UPNP_SEARCH_RESPONSE_TIMEOUT), + ..Default::default() + }) + .await + .with_context(|| format!("search igd gateway for {local_listener}"))?; + let local_addr = resolve_internal_addr(gateway.addr, local_listener).await?; + + Ok((gateway, local_addr)) + } + + async fn establish_via_igd( + local_listener: &url::Url, + gateway: TokioGateway, + local_addr: SocketAddr, + ) -> anyhow::Result { + let gateway_external_port = add_udp_mapping_port_igd(&gateway, local_addr, local_listener) + .await + .with_context(|| { + format!( + "map udp socket for {local_listener} via gateway {}", + gateway.addr + ) + })?; + + Ok(Self { + backend: PortMappingBackend::Igd { gateway }, + local_listener: local_listener.clone(), + local_addr, + gateway_external_port, + }) + } + + fn backend_name(&self) -> &'static str { + self.backend.name() + } + + async fn renew(&self) -> anyhow::Result<()> { + match &self.backend { + PortMappingBackend::NatPmp { gateway } => { + renew_udp_mapping_nat_pmp( + *gateway, + self.local_addr, + self.gateway_external_port, + &self.local_listener, + ) + .await + } + PortMappingBackend::Igd { gateway } => { + renew_udp_mapping_igd( + gateway, + self.local_addr, + self.gateway_external_port, + &self.local_listener, + ) + .await + } + } + } + + async fn remove(&self) -> anyhow::Result<()> { + match &self.backend { + PortMappingBackend::NatPmp { gateway } => { + remove_udp_mapping_nat_pmp( + *gateway, + self.local_addr, + self.gateway_external_port, + &self.local_listener, + ) + .await + } + PortMappingBackend::Igd { gateway } => { + remove_udp_mapping_igd(gateway, self.gateway_external_port, &self.local_listener) + .await + } + } + } +} + +pub struct UdpPortMappingLease { + backend: &'static str, + gateway_external_port: u16, + stop_tx: Option>, +} + +impl UdpPortMappingLease { + pub fn backend(&self) -> &'static str { + self.backend + } + + pub fn gateway_external_port(&self) -> u16 { + self.gateway_external_port + } +} + +impl fmt::Debug for UdpPortMappingLease { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UdpPortMappingLease") + .field("backend", &self.backend) + .field("gateway_external_port", &self.gateway_external_port) + .finish() + } +} + +impl Drop for UdpPortMappingLease { + fn drop(&mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + } +} + +pub async fn resolve_udp_public_addr( + global_ctx: ArcGlobalCtx, + local_listener: &url::Url, + socket: Arc, +) -> anyhow::Result<(SocketAddr, Option)> { + let port_mapping = match try_start_udp_port_mapping(&global_ctx, local_listener).await { + Ok(mapping) => mapping, + Err(err) => { + tracing::warn!( + ?err, + %local_listener, + "failed to establish udp port mapping, fallback to stun-only public addr resolution" + ); + None + } + }; + + let mapped_addr = global_ctx + .get_stun_info_collector() + .get_udp_port_mapping_with_socket(socket) + .await + .map_err(anyhow::Error::from) + .with_context(|| format!("resolve udp public addr for {local_listener}"))?; + + if let Some(port_mapping) = port_mapping.as_ref() { + let mapped_listener = build_url_from_socket_addr(&mapped_addr.to_string(), "udp"); + global_ctx.issue_event(GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener: local_listener.clone(), + mapped_listener, + backend: port_mapping.backend().to_string(), + }); + tracing::info!( + %local_listener, + backend = port_mapping.backend(), + gateway_external_port = port_mapping.gateway_external_port(), + stun_mapped_addr = %mapped_addr, + "udp public addr resolved after port mapping" + ); + } else { + tracing::debug!( + %local_listener, + stun_mapped_addr = %mapped_addr, + "udp public addr resolved without port mapping" + ); + } + + Ok((mapped_addr, port_mapping)) +} + +async fn try_start_udp_port_mapping( + global_ctx: &ArcGlobalCtx, + local_listener: &url::Url, +) -> anyhow::Result> { + if global_ctx.get_flags().disable_upnp || !should_map_udp_listener(local_listener) { + return Ok(None); + } + + let mapping = discover_udp_port_mapping(global_ctx.clone(), local_listener.clone()).await?; + tracing::info!( + %local_listener, + backend = mapping.backend_name(), + local_addr = %mapping.local_addr, + gateway_external_port = mapping.gateway_external_port, + "udp port mapping established" + ); + + let backend = mapping.backend_name(); + let gateway_external_port = mapping.gateway_external_port; + let runtime_global_ctx = global_ctx.clone(); + let runtime_local_listener = local_listener.clone(); + let (stop_tx, stop_rx) = oneshot::channel(); + if should_run_port_mapping_in_dedicated_thread(&runtime_global_ctx) { + tokio::task::spawn_blocking(move || { + let _g = runtime_global_ctx.net_ns.guard(); + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => { + runtime.block_on(run_udp_port_mapping_task( + runtime_local_listener, + mapping, + stop_rx, + )); + } + Err(err) => { + tracing::error!( + ?err, + %runtime_local_listener, + "failed to build runtime for udp port mapping renew task" + ); + } + } + }); + } else { + tokio::spawn(run_udp_port_mapping_task( + runtime_local_listener, + mapping, + stop_rx, + )); + } + + Ok(Some(UdpPortMappingLease { + backend, + gateway_external_port, + stop_tx: Some(stop_tx), + })) +} + +async fn discover_udp_port_mapping( + global_ctx: ArcGlobalCtx, + local_listener: url::Url, +) -> anyhow::Result { + match discover_igd_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await { + Ok((gateway, local_addr)) => match establish_igd_mapping_in_netns( + global_ctx.clone(), + local_listener.clone(), + gateway, + local_addr, + ) + .await + { + Ok(mapping) => Ok(mapping), + Err(igd_err) => { + tracing::debug!( + ?igd_err, + %local_listener, + "igd udp port mapping failed, retry with nat-pmp" + ); + match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone()) + .await + { + Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns( + global_ctx, + local_listener.clone(), + gateway, + local_addr, + ) + .await + .map_err(|nat_pmp_err| { + anyhow!( + "udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp error: {nat_pmp_err}" + ) + }), + Err(nat_pmp_err) => Err(anyhow!( + "udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}" + )), + } + } + }, + Err(igd_err) => { + tracing::debug!( + ?igd_err, + %local_listener, + "igd gateway discovery failed, retry with nat-pmp" + ); + match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await + { + Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns( + global_ctx, + local_listener.clone(), + gateway, + local_addr, + ) + .await + .map_err(|nat_pmp_err| { + anyhow!( + "udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp error: {nat_pmp_err}" + ) + }), + Err(nat_pmp_err) => Err(anyhow!( + "udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}" + )), + } + } + } +} + +async fn discover_igd_gateway_in_netns( + global_ctx: ArcGlobalCtx, + local_listener: url::Url, +) -> anyhow::Result<(TokioGateway, SocketAddr)> { + if !should_run_port_mapping_in_dedicated_thread(&global_ctx) { + return ActiveUdpPortMapping::discover_igd_gateway(&global_ctx, &local_listener).await; + } + + tokio::task::spawn_blocking(move || { + let _g = global_ctx.net_ns.guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for igd gateway discovery")? + .block_on(ActiveUdpPortMapping::discover_igd_gateway( + &global_ctx, + &local_listener, + )) + }) + .await + .context("join igd gateway discovery task")? +} + +async fn establish_igd_mapping_in_netns( + global_ctx: ArcGlobalCtx, + local_listener: url::Url, + gateway: TokioGateway, + local_addr: SocketAddr, +) -> anyhow::Result { + if !should_run_port_mapping_in_dedicated_thread(&global_ctx) { + return ActiveUdpPortMapping::establish_via_igd(&local_listener, gateway, local_addr).await; + } + + tokio::task::spawn_blocking(move || { + let _g = global_ctx.net_ns.guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for igd mapping establishment")? + .block_on(ActiveUdpPortMapping::establish_via_igd( + &local_listener, + gateway, + local_addr, + )) + }) + .await + .context("join igd mapping establishment task")? +} + +async fn discover_nat_pmp_gateway_in_netns( + global_ctx: ArcGlobalCtx, + local_listener: url::Url, +) -> anyhow::Result<(Ipv4Addr, SocketAddr)> { + if !should_run_port_mapping_in_dedicated_thread(&global_ctx) { + return ActiveUdpPortMapping::discover_nat_pmp_gateway(&local_listener).await; + } + + tokio::task::spawn_blocking(move || { + let _g = global_ctx.net_ns.guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for nat-pmp gateway discovery")? + .block_on(ActiveUdpPortMapping::discover_nat_pmp_gateway( + &local_listener, + )) + }) + .await + .context("join nat-pmp gateway discovery task")? +} + +async fn establish_nat_pmp_mapping_in_netns( + global_ctx: ArcGlobalCtx, + local_listener: url::Url, + gateway: Ipv4Addr, + local_addr: SocketAddr, +) -> anyhow::Result { + if !should_run_port_mapping_in_dedicated_thread(&global_ctx) { + return ActiveUdpPortMapping::establish_via_nat_pmp(&local_listener, gateway, local_addr) + .await; + } + + tokio::task::spawn_blocking(move || { + let _g = global_ctx.net_ns.guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for nat-pmp mapping establishment")? + .block_on(ActiveUdpPortMapping::establish_via_nat_pmp( + &local_listener, + gateway, + local_addr, + )) + }) + .await + .context("join nat-pmp mapping establishment task")? +} + +async fn run_udp_port_mapping_task( + local_listener: url::Url, + mapping: ActiveUdpPortMapping, + mut stop_rx: oneshot::Receiver<()>, +) { + loop { + tokio::select! { + _ = tokio::time::sleep(UPNP_RENEW_INTERVAL) => { + if let Err(err) = mapping.renew().await { + tracing::warn!( + ?err, + %local_listener, + backend = mapping.backend_name(), + gateway_external_port = mapping.gateway_external_port, + "failed to renew udp port mapping" + ); + } + } + _ = &mut stop_rx => break, + } + } + + if let Err(err) = mapping.remove().await { + tracing::debug!( + ?err, + %local_listener, + backend = mapping.backend_name(), + gateway_external_port = mapping.gateway_external_port, + "failed to remove udp port mapping" + ); + } +} + +fn should_run_port_mapping_in_dedicated_thread(global_ctx: &ArcGlobalCtx) -> bool { + global_ctx.net_ns.name().is_some() +} + +async fn add_udp_mapping_port_igd( + gateway: &TokioGateway, + local_addr: SocketAddr, + local_listener: &url::Url, +) -> anyhow::Result { + match gateway + .add_any_port( + PortMappingProtocol::UDP, + local_addr, + UPNP_LEASE_DURATION_SECS, + UPNP_DESCRIPTION, + ) + .await + { + Ok(external_port) => Ok(external_port), + Err(AddAnyPortError::RequestError(err)) => { + tracing::debug!( + ?err, + %local_listener, + gateway = %gateway.addr, + %local_addr, + "igd any-port udp mapping failed, retry with same-port mapping" + ); + + gateway + .add_port( + PortMappingProtocol::UDP, + local_addr.port(), + local_addr, + UPNP_LEASE_DURATION_SECS, + UPNP_DESCRIPTION, + ) + .await + .map(|_| local_addr.port()) + .map_err(|same_port_err| { + anyhow!( + "igd udp mapping failed for {local_listener}: any-port error: {err}; same-port error: {same_port_err}" + ) + }) + } + Err(err) => Err(err.into()), + } +} + +async fn add_udp_mapping_port_nat_pmp( + gateway: Ipv4Addr, + local_addr: SocketAddr, + local_listener: &url::Url, +) -> anyhow::Result { + match request_nat_pmp_mapping(gateway, local_addr.port(), 0, UPNP_LEASE_DURATION_SECS).await { + Ok(external_port) => Ok(external_port), + Err(any_port_err) => { + tracing::debug!( + ?any_port_err, + %local_listener, + gateway = %gateway, + %local_addr, + "nat-pmp any-port udp mapping failed, retry with same-port mapping" + ); + + request_nat_pmp_mapping( + gateway, + local_addr.port(), + local_addr.port(), + UPNP_LEASE_DURATION_SECS, + ) + .await + .map_err(|same_port_err| { + anyhow!( + "nat-pmp udp mapping failed for {local_listener}: any-port error: {any_port_err}; same-port error: {same_port_err}" + ) + }) + } + } +} + +async fn request_nat_pmp_mapping( + gateway: Ipv4Addr, + private_port: u16, + public_port: u16, + lifetime_secs: u32, +) -> anyhow::Result { + let client = new_tokio_natpmp_with(gateway) + .await + .with_context(|| format!("create nat-pmp client for gateway {gateway}"))?; + client + .send_port_mapping_request( + NatPmpProtocol::UDP, + private_port, + public_port, + lifetime_secs, + ) + .await + .with_context(|| { + format!( + "send nat-pmp udp mapping request private_port={private_port} public_port={public_port} gateway={gateway}" + ) + })?; + + let response = tokio::time::timeout(NAT_PMP_RESPONSE_TIMEOUT, client.read_response_or_retry()) + .await + .with_context(|| { + format!( + "wait nat-pmp udp mapping response private_port={private_port} gateway={gateway}" + ) + })? + .map_err(anyhow::Error::from) + .with_context(|| { + format!( + "read nat-pmp udp mapping response private_port={private_port} gateway={gateway}" + ) + })?; + + match response { + NatPmpResponse::UDP(mapping) | NatPmpResponse::TCP(mapping) => Ok(mapping.public_port()), + NatPmpResponse::Gateway(_) => { + bail!("unexpected nat-pmp gateway response for udp mapping request") + } + } +} + +async fn renew_udp_mapping_nat_pmp( + gateway: Ipv4Addr, + local_addr: SocketAddr, + external_port: u16, + local_listener: &url::Url, +) -> anyhow::Result<()> { + request_nat_pmp_mapping( + gateway, + local_addr.port(), + external_port, + UPNP_LEASE_DURATION_SECS, + ) + .await + .map(|_| ()) + .with_context(|| format!("renew udp port mapping {local_listener}")) +} + +async fn remove_udp_mapping_nat_pmp( + gateway: Ipv4Addr, + local_addr: SocketAddr, + external_port: u16, + local_listener: &url::Url, +) -> anyhow::Result<()> { + request_nat_pmp_mapping(gateway, local_addr.port(), external_port, 0) + .await + .map(|_| ()) + .with_context(|| format!("remove udp port mapping {local_listener}")) +} + +fn should_map_udp_listener(local_listener: &url::Url) -> bool { + if local_listener.scheme() != "udp" { + return false; + } + + let Some(host) = listener_ipv4_host(local_listener) else { + return false; + }; + + if host.is_loopback() || host.is_broadcast() { + return false; + } + + host.is_unspecified() || host.is_private() || host.is_link_local() +} + +fn listener_ipv4_host(local_listener: &url::Url) -> Option { + local_listener.host_str()?.parse().ok() +} + +async fn resolve_internal_addr( + gateway_addr: SocketAddr, + local_listener: &url::Url, +) -> anyhow::Result { + let port = local_listener + .port() + .ok_or_else(|| anyhow!("listener port is missing"))?; + let host = + listener_ipv4_host(local_listener).ok_or_else(|| anyhow!("listener must be ipv4"))?; + + let ip = if host.is_unspecified() { + let udp = std::net::UdpSocket::bind("0.0.0.0:0") + .context("bind probe socket for gateway route")?; + udp.connect(gateway_addr) + .with_context(|| format!("connect probe socket to gateway {gateway_addr}"))?; + let SocketAddr::V4(local_addr) = udp.local_addr().context("get probe socket local addr")? + else { + bail!("gateway route selected a non-ipv4 local address"); + }; + *local_addr.ip() + } else { + host + }; + + Ok(SocketAddr::new(ip.into(), port)) +} + +async fn renew_udp_mapping_igd( + gateway: &TokioGateway, + local_addr: SocketAddr, + external_port: u16, + local_listener: &url::Url, +) -> anyhow::Result<()> { + gateway + .add_port( + PortMappingProtocol::UDP, + external_port, + local_addr, + UPNP_LEASE_DURATION_SECS, + UPNP_DESCRIPTION, + ) + .await + .with_context(|| format!("renew udp port mapping {local_listener}")) +} + +async fn remove_udp_mapping_igd( + gateway: &TokioGateway, + external_port: u16, + local_listener: &url::Url, +) -> anyhow::Result<()> { + gateway + .remove_port(PortMappingProtocol::UDP, external_port) + .await + .with_context(|| format!("remove udp port mapping {local_listener}")) +} + +#[cfg(test)] +mod tests { + #[test] + fn udp_mapping_requires_private_or_unspecified_ipv4_listener() { + assert!(super::should_map_udp_listener( + &"udp://0.0.0.0:11010".parse().unwrap() + )); + assert!(super::should_map_udp_listener( + &"udp://192.168.1.10:11010".parse().unwrap() + )); + assert!(!super::should_map_udp_listener( + &"udp://127.0.0.1:11010".parse().unwrap() + )); + assert!(!super::should_map_udp_listener( + &"udp://8.8.8.8:11010".parse().unwrap() + )); + assert!(!super::should_map_udp_listener( + &"tcp://0.0.0.0:11010".parse().unwrap() + )); + } +} diff --git a/easytier/src/connector/direct.rs b/easytier/src/connector/direct.rs index 6a5b63e1..452128f5 100644 --- a/easytier/src/connector/direct.rs +++ b/easytier/src/connector/direct.rs @@ -2,7 +2,7 @@ use std::{ collections::HashSet, - net::{IpAddr, Ipv6Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, str::FromStr, sync::{ Arc, @@ -27,7 +27,7 @@ use crate::{ proto::{ peer_rpc::{ DirectConnectorRpc, DirectConnectorRpcClientFactory, DirectConnectorRpcServer, - GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest, + GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest, }, rpc_types::controller::BaseController, }, @@ -117,37 +117,25 @@ impl DirectConnectorManagerData { } } - async fn remote_send_v6_hole_punch_packet( + async fn remote_send_udp_hole_punch_packet( &self, dst_peer_id: PeerId, - local_socket: &UdpSocket, + connector_addr: SocketAddr, remote_url: &url::Url, ) -> Result<(), Error> { + if !matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) { + return Err(anyhow::anyhow!( + "udp hole punch packet only applies to udp listener: {}", + remote_url + ) + .into()); + } + let global_ctx = self.peer_manager.get_global_ctx(); let listener_port = remote_url.port().ok_or(anyhow::anyhow!( "failed to parse port from remote url: {}", remote_url ))?; - let connector_ip = global_ctx - .get_stun_info_collector() - .get_stun_info() - .public_ip - .iter() - .find(|x| x.contains(":")) - .ok_or(anyhow::anyhow!( - "failed to get public ipv6 address from stun info" - ))? - .parse::() - .with_context(|| { - format!( - "failed to parse public ipv6 address from stun info: {:?}", - global_ctx.get_stun_info_collector().get_stun_info() - ) - })?; - let connector_addr = SocketAddr::new( - std::net::IpAddr::V6(connector_ip), - local_socket.local_addr()?.port(), - ); let rpc_stub = self .peer_manager @@ -160,9 +148,9 @@ impl DirectConnectorManagerData { ); rpc_stub - .send_v6_hole_punch_packet( + .send_udp_hole_punch_packet( BaseController::default(), - SendV6HolePunchPacketRequest { + SendUdpHolePunchPacketRequest { listener_port: listener_port as u32, connector_addr: Some(connector_addr.into()), }, @@ -170,7 +158,7 @@ impl DirectConnectorManagerData { .await .with_context(|| { format!( - "do rpc, send v6 hole punch packet to peer {} at {}", + "do rpc, send udp hole punch packet to peer {} at {}", dst_peer_id, remote_url ) })?; @@ -188,11 +176,34 @@ impl DirectConnectorManagerData { .await .with_context(|| format!("failed to bind local socket for {}", remote_url))?, ); + let connector_ip = self + .peer_manager + .get_global_ctx() + .get_stun_info_collector() + .get_stun_info() + .public_ip + .iter() + .find(|x| x.contains(':')) + .ok_or(anyhow::anyhow!( + "failed to get public ipv6 address from stun info" + ))? + .parse::() + .with_context(|| { + format!( + "failed to parse public ipv6 address from stun info: {:?}", + self.peer_manager + .get_global_ctx() + .get_stun_info_collector() + .get_stun_info() + ) + })?; + let connector_addr = + SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port()); // ask remote to send v6 hole punch packet // and no matter what the result is, continue to connect let _ = self - .remote_send_v6_hole_punch_packet(dst_peer_id, &local_socket, remote_url) + .remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url) .await; let udp_connector = UdpTunnelConnector::new(remote_url.clone()); @@ -207,14 +218,80 @@ impl DirectConnectorManagerData { .await } + async fn connect_to_public_ipv4( + &self, + dst_peer_id: PeerId, + remote_url: &url::Url, + ) -> Result<(PeerId, PeerConnId), Error> { + let local_socket = { + let _g = self.global_ctx.net_ns.guard(); + Arc::new( + UdpSocket::bind("0.0.0.0:0") + .await + .with_context(|| format!("failed to bind local socket for {}", remote_url))?, + ) + }; + let connector_addr = self + .peer_manager + .get_global_ctx() + .get_stun_info_collector() + .get_udp_port_mapping_with_socket(local_socket.clone()) + .await + .with_context(|| format!("failed to get udp port mapping for {}", remote_url))?; + + let _ = self + .remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url) + .await; + + let udp_connector = UdpTunnelConnector::new(remote_url.clone()); + let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V4).await?; + let ret = udp_connector + .try_connect_with_socket(local_socket, remote_addr) + .await?; + + self.peer_manager + .add_client_tunnel_with_peer_id_hint(ret, true, Some(dst_peer_id)) + .await + } + async fn do_try_connect_to_ip(&self, dst_peer_id: PeerId, addr: String) -> Result<(), Error> { let connector = create_connector_by_url(&addr, &self.global_ctx, IpVersion::Both).await?; let remote_url = connector.remote_url(); - let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) - && matches!(remote_url.host(), Some(Host::Ipv6(_))) - { - self.connect_to_public_ipv6(dst_peer_id, &remote_url) - .await? + let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) { + match remote_url.host() { + Some(Host::Ipv6(_)) => { + self.connect_to_public_ipv6(dst_peer_id, &remote_url) + .await? + } + Some(Host::Ipv4(ip)) if is_public_ipv4(ip) => { + match self.connect_to_public_ipv4(dst_peer_id, &remote_url).await { + Ok(ret) => ret, + Err(err) => { + tracing::debug!( + ?err, + %remote_url, + "udp public ipv4 listener punch failed, falling back to direct connect" + ); + timeout( + std::time::Duration::from_secs(3), + self.peer_manager.try_direct_connect_with_peer_id_hint( + connector, + Some(dst_peer_id), + ), + ) + .await?? + } + } + } + _ => { + timeout( + std::time::Duration::from_secs(3), + self.peer_manager + .try_direct_connect_with_peer_id_hint(connector, Some(dst_peer_id)), + ) + .await?? + } + } } else { timeout( std::time::Duration::from_secs(3), @@ -577,6 +654,14 @@ impl DirectConnectorManagerData { } } +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_broadcast() + && !ip.is_unspecified() +} + impl std::fmt::Debug for DirectConnectorManagerData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DirectConnectorManagerData") diff --git a/easytier/src/connector/tcp_hole_punch.rs b/easytier/src/connector/tcp_hole_punch.rs index 024ac163..9baaeaa0 100644 --- a/easytier/src/connector/tcp_hole_punch.rs +++ b/easytier/src/connector/tcp_hole_punch.rs @@ -621,6 +621,13 @@ mod tests { Ok(format!("127.0.0.1:{}", port).parse().unwrap()) } + async fn get_udp_port_mapping_with_socket( + &self, + udp: std::sync::Arc, + ) -> Result { + self.get_udp_port_mapping(udp.local_addr()?.port()).await + } + async fn get_tcp_port_mapping(&self, mut port: u16) -> Result { if port == 0 { port = 40144; diff --git a/easytier/src/connector/udp_hole_punch/common.rs b/easytier/src/connector/udp_hole_punch/common.rs index c99b8094..b4cf52f1 100644 --- a/easytier/src/connector/udp_hole_punch/common.rs +++ b/easytier/src/connector/udp_hole_punch/common.rs @@ -13,8 +13,7 @@ use zerocopy::FromBytes as _; use crate::{ common::{ - PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS, - stun::StunInfoCollectorTrait as _, + PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS, upnp, }, defer, peers::peer_manager::PeerManager, @@ -27,6 +26,7 @@ use crate::{ }; pub(crate) const HOLE_PUNCH_PACKET_BODY_LEN: u16 = 16; +const MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS: usize = 4; fn generate_shuffled_port_vec() -> Vec { let mut rng = rand::thread_rng(); @@ -352,6 +352,8 @@ pub(crate) struct UdpHolePunchListener { tasks: JoinSet<()>, running: Arc>, mapped_addr: SocketAddr, + has_port_mapping_lease: bool, + _port_mapping_lease: Option, conn_counter: Arc>, listen_time: std::time::Instant, @@ -360,11 +362,6 @@ pub(crate) struct UdpHolePunchListener { } impl UdpHolePunchListener { - async fn get_avail_port() -> Result { - let socket = UdpSocket::bind("0.0.0.0:0").await?; - Ok(socket.local_addr()?.port()) - } - #[instrument(err)] pub async fn new(peer_mgr: Arc) -> Result { Self::new_ext(peer_mgr, true, None).await @@ -376,18 +373,24 @@ impl UdpHolePunchListener { with_mapped_addr: bool, port: Option, ) -> Result { - let port = port.unwrap_or(Self::get_avail_port().await?); - let listen_url = format!("udp://0.0.0.0:{}", port); + let socket = { + let _g = peer_mgr.get_global_ctx().net_ns.guard(); + Arc::new(UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port.unwrap_or(0))).await?) + }; + let local_port = socket.local_addr()?.port(); + let listen_url: url::Url = format!("udp://0.0.0.0:{local_port}").parse().unwrap(); - let mapped_addr = if with_mapped_addr { - let gctx = peer_mgr.get_global_ctx(); - let stun_info_collect = gctx.get_stun_info_collector(); - stun_info_collect.get_udp_port_mapping(port).await? + let (mapped_addr, port_mapping_lease) = if with_mapped_addr { + upnp::resolve_udp_public_addr(peer_mgr.get_global_ctx(), &listen_url, socket.clone()) + .await? } else { - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), port)) + ( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, local_port)), + None, + ) }; - let mut listener = UdpTunnelListener::new(listen_url.parse().unwrap()); + let mut listener = UdpTunnelListener::new_with_socket(listen_url, socket.clone()); { let _g = peer_mgr.get_global_ctx().net_ns.guard(); @@ -437,6 +440,8 @@ impl UdpHolePunchListener { socket, running, mapped_addr, + has_port_mapping_lease: port_mapping_lease.is_some(), + _port_mapping_lease: port_mapping_lease, conn_counter, listen_time: std::time::Instant::now(), @@ -517,45 +522,87 @@ impl PunchHoleServerCommon { pub(crate) async fn select_listener( &self, use_new_listener: bool, + prefer_port_mapping: bool, ) -> Option<(Arc, SocketAddr)> { - let all_listener_sockets = &self.listeners; - - let mut use_last = false; - if all_listener_sockets.lock().await.len() < 16 || use_new_listener { - tracing::warn!("creating new udp hole punching listener"); - all_listener_sockets.lock().await.push( - UdpHolePunchListener::new(self.peer_mgr.clone()) - .await - .ok()?, - ); - use_last = true; - } - - let mut locked = all_listener_sockets.lock().await; - - let listener = if use_last { - Some(locked.last_mut()?) - } else { - // use the listener that is active most recently - locked - .iter_mut() - .filter(|l| !l.mapped_addr.ip().is_unspecified()) - .max_by_key(|listener| listener.last_active_time.load()) + let (listener_count, has_reusable_listener, has_port_mapping_listener) = { + let locked = self.listeners.lock().await; + ( + locked.len(), + locked.iter().any(can_reuse_public_listener), + locked.iter().any(can_reuse_port_mapping_listener), + ) }; + let should_create = should_create_public_listener( + listener_count, + has_reusable_listener, + has_port_mapping_listener, + use_new_listener, + prefer_port_mapping, + ); - if listener.is_none() || listener.as_ref().unwrap().mapped_addr.ip().is_unspecified() { + if should_create { tracing::warn!( - ?use_new_listener, - "no available udp hole punching listener with mapped address" + max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + "creating udp hole punching listener" ); - if !use_new_listener { - return self.select_listener(true).await; - } else { - return None; + match UdpHolePunchListener::new(self.peer_mgr.clone()).await { + Ok(listener) => self.listeners.lock().await.push(listener), + Err(err) => { + tracing::warn!(?err, "failed to create udp hole punching listener"); + } } } - let listener = listener.unwrap(); + let mut locked = self.listeners.lock().await; + let listener_count = locked.len(); + let listener_idx = if prefer_port_mapping { + select_reusable_port_mapping_listener_idx(locked.as_slice()) + .or_else(|| { + if should_create && locked.last().is_some_and(can_reuse_public_listener) { + Some(locked.len() - 1) + } else { + None + } + }) + .or_else(|| select_reusable_public_listener_idx(locked.as_slice())) + } else if should_create { + locked.len().checked_sub(1) + } else { + select_reusable_public_listener_idx(locked.as_slice()) + }; + + let Some(listener_idx) = listener_idx else { + tracing::warn!( + ?use_new_listener, + ?prefer_port_mapping, + listener_count, + max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + "no available udp hole punching listener with mapped address" + ); + if should_retry_public_listener_selection( + use_new_listener, + listener_count, + prefer_port_mapping, + has_port_mapping_listener, + ) { + drop(locked); + return self.select_listener(true, prefer_port_mapping).await; + } + return None; + }; + + let listener = &mut locked[listener_idx]; + if !can_reuse_public_listener(listener) { + tracing::warn!( + ?use_new_listener, + ?prefer_port_mapping, + listener_count, + max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + "selected udp hole punching listener is not reusable" + ); + return None; + } + Some((listener.get_socket().await, listener.mapped_addr)) } @@ -572,7 +619,73 @@ impl PunchHoleServerCommon { } } -#[tracing::instrument(err, ret(level=Level::DEBUG), skip(ports))] +fn can_reuse_public_listener(listener: &UdpHolePunchListener) -> bool { + listener.running.load() && !listener.mapped_addr.ip().is_unspecified() +} + +fn can_reuse_port_mapping_listener(listener: &UdpHolePunchListener) -> bool { + can_reuse_public_listener(listener) && listener.has_port_mapping_lease +} + +fn select_reusable_public_listener_idx(listeners: &[UdpHolePunchListener]) -> Option { + // Reuse the listener that was active most recently. + listeners + .iter() + .enumerate() + .filter(|(_, listener)| can_reuse_public_listener(listener)) + .max_by_key(|(_, listener)| listener.last_active_time.load()) + .map(|(idx, _)| idx) +} + +fn select_reusable_port_mapping_listener_idx(listeners: &[UdpHolePunchListener]) -> Option { + listeners + .iter() + .enumerate() + .filter(|(_, listener)| can_reuse_port_mapping_listener(listener)) + .max_by_key(|(_, listener)| listener.last_active_time.load()) + .map(|(idx, _)| idx) +} + +fn should_create_public_listener( + current_listener_count: usize, + has_reusable_listener: bool, + has_port_mapping_listener: bool, + force_new_listener: bool, + prefer_port_mapping: bool, +) -> bool { + if current_listener_count >= MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS { + return false; + } + + if current_listener_count == 0 { + return true; + } + + if force_new_listener { + return true; + } + + if prefer_port_mapping && !has_port_mapping_listener { + return true; + } + + !has_reusable_listener +} + +fn should_retry_public_listener_selection( + force_new_listener: bool, + current_listener_count: usize, + prefer_port_mapping: bool, + has_port_mapping_listener: bool, +) -> bool { + if prefer_port_mapping && has_port_mapping_listener { + return false; + } + + !force_new_listener && current_listener_count < MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS +} + +#[tracing::instrument(err, ret(level=Level::DEBUG))] pub(crate) async fn send_symmetric_hole_punch_packet( ports: &[u16], udp: Arc, @@ -647,3 +760,66 @@ pub(crate) async fn try_connect_with_socket( .await .map_err(Error::from) } + +#[cfg(test)] +mod tests { + use super::{ + MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, should_create_public_listener, + should_retry_public_listener_selection, + }; + + #[test] + fn listener_selection_prefers_reuse_before_cap() { + assert!(!should_create_public_listener(1, true, true, false, false)); + assert!(!should_create_public_listener( + MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + true, + true, + false, + false + )); + } + + #[test] + fn listener_selection_creates_when_empty_or_no_reusable_listener() { + assert!(should_create_public_listener(0, false, false, false, false)); + assert!(should_create_public_listener(1, false, false, false, false)); + } + + #[test] + fn listener_selection_force_new_respects_cap() { + assert!(should_create_public_listener(1, true, true, true, false)); + assert!(!should_create_public_listener( + MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + true, + true, + true, + false + )); + } + + #[test] + fn listener_selection_prefers_port_mapping_until_available() { + assert!(should_create_public_listener(1, true, false, false, true)); + assert!(!should_create_public_listener(1, true, true, false, true)); + } + + #[test] + fn listener_selection_retry_respects_cap() { + assert!(should_retry_public_listener_selection( + false, 1, false, false + )); + assert!(!should_retry_public_listener_selection( + false, + MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, + false, + false + )); + assert!(!should_retry_public_listener_selection( + true, 1, false, false + )); + assert!(!should_retry_public_listener_selection( + false, 1, true, true + )); + } +} diff --git a/easytier/src/connector/udp_hole_punch/cone.rs b/easytier/src/connector/udp_hole_punch/cone.rs index 71be9519..5d75f981 100644 --- a/easytier/src/connector/udp_hole_punch/cone.rs +++ b/easytier/src/connector/udp_hole_punch/cone.rs @@ -7,7 +7,7 @@ use anyhow::Context; use tokio::net::UdpSocket; use crate::{ - common::{PeerId, scoped_task::ScopedTask, stun::StunInfoCollectorTrait}, + common::{PeerId, scoped_task::ScopedTask, upnp}, connector::udp_hole_punch::common::{ HOLE_PUNCH_PACKET_BODY_LEN, UdpSocketArray, try_connect_with_socket, }, @@ -117,23 +117,19 @@ impl PunchConeHoleClient { let _g = self.peer_mgr.get_global_ctx().net_ns.guard(); Arc::new(UdpSocket::bind("0.0.0.0:0").await?) }; - let local_addr = local_socket .local_addr() - .with_context(|| "failed to get local port from udp array")?; - let local_port = local_addr.port(); - - drop(local_socket); - let local_mapped_addr = global_ctx - .get_stun_info_collector() - .get_udp_port_mapping(local_port) - .await - .with_context(|| "failed to get udp port mapping")?; - - let local_socket = { - let _g = self.peer_mgr.get_global_ctx().net_ns.guard(); - Arc::new(UdpSocket::bind(local_addr).await?) - }; + .with_context(|| "failed to get local addr from udp punch socket")?; + let local_listener: url::Url = format!("udp://0.0.0.0:{}", local_addr.port()) + .parse() + .unwrap(); + let (local_mapped_addr, _local_port_mapping_lease) = upnp::resolve_udp_public_addr( + global_ctx.clone(), + &local_listener, + local_socket.clone(), + ) + .await + .with_context(|| "failed to resolve udp public addr for cone hole punch")?; // client -> server: tell server the mapped port, server will return the mapped address of listening port. let rpc_stub = self @@ -149,7 +145,10 @@ impl PunchConeHoleClient { let resp = rpc_stub .select_punch_listener( BaseController::default(), - SelectPunchListenerRequest { force_new: false }, + SelectPunchListenerRequest { + force_new: false, + prefer_port_mapping: true, + }, ) .await; diff --git a/easytier/src/connector/udp_hole_punch/mod.rs b/easytier/src/connector/udp_hole_punch/mod.rs index 779d428b..e1c2e14b 100644 --- a/easytier/src/connector/udp_hole_punch/mod.rs +++ b/easytier/src/connector/udp_hole_punch/mod.rs @@ -88,7 +88,7 @@ impl UdpHolePunchRpc for UdpHolePunchServer { ) -> rpc_types::error::Result { let (_, addr) = self .common - .select_listener(input.force_new) + .select_listener(input.force_new, input.prefer_port_mapping) .await .ok_or(anyhow::anyhow!("no listener available"))?; @@ -584,6 +584,11 @@ impl UdpHolePunchConnector { Ok(()) } + + #[cfg(test)] + pub async fn run_immediately_for_test(&self) { + self.client.run_immediately().await; + } } #[cfg(test)] @@ -614,6 +619,9 @@ pub mod tests { udp_nat_type: NatType, ) -> Arc { let p_a = create_mock_peer_manager().await; + let mut flags = p_a.get_global_ctx().get_flags(); + flags.disable_upnp = true; + p_a.get_global_ctx().set_flags(flags); replace_stun_info_collector(p_a.clone(), udp_nat_type); p_a } diff --git a/easytier/src/connector/udp_hole_punch/sym_to_cone.rs b/easytier/src/connector/udp_hole_punch/sym_to_cone.rs index fc8973b4..f2475fa0 100644 --- a/easytier/src/connector/udp_hole_punch/sym_to_cone.rs +++ b/easytier/src/connector/udp_hole_punch/sym_to_cone.rs @@ -434,7 +434,10 @@ impl PunchSymToConeHoleClient { let resp = rpc_stub .select_punch_listener( BaseController::default(), - SelectPunchListenerRequest { force_new: false }, + SelectPunchListenerRequest { + force_new: false, + prefer_port_mapping: true, + }, ) .await; diff --git a/easytier/src/core.rs b/easytier/src/core.rs index f57daf14..ada771ba 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -450,6 +450,15 @@ struct NetworkOptions { )] disable_sym_hole_punching: Option, + #[arg( + long, + env = "ET_DISABLE_UPNP", + help = t!("core_clap.disable_upnp").to_string(), + num_args = 0..=1, + default_missing_value = "true" + )] + disable_upnp: Option, + #[arg( long, env = "ET_RELAY_ALL_PEER_RPC", @@ -1101,7 +1110,10 @@ impl NetworkOptions { f.enable_relay_foreign_network_quic = self .enable_relay_foreign_network_quic .unwrap_or(f.enable_relay_foreign_network_quic); - f.disable_sym_hole_punching = self.disable_sym_hole_punching.unwrap_or(false); + f.disable_sym_hole_punching = self + .disable_sym_hole_punching + .unwrap_or(f.disable_sym_hole_punching); + f.disable_upnp = self.disable_upnp.unwrap_or(f.disable_upnp); // Configure tld_dns_zone: use provided value if set if let Some(tld_dns_zone) = &self.tld_dns_zone { f.tld_dns_zone = tld_dns_zone.clone(); diff --git a/easytier/src/instance_manager.rs b/easytier/src/instance_manager.rs index 4ad710e8..460c3772 100644 --- a/easytier/src/instance_manager.rs +++ b/easytier/src/instance_manager.rs @@ -355,6 +355,21 @@ fn handle_event( event!(info, category: "CONNECTION", local, remote, err, "[{}] connection error", instance_id); } + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + event!( + info, + %local_listener, + %mapped_listener, + backend, + "[{}] listener port mapping established", + instance_id + ); + } + GlobalCtxEvent::TunDeviceReady(dev) => { event!(info, dev, "[{}] tun device ready", instance_id); } diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 3ae57af4..0d64bb84 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -801,6 +801,10 @@ impl NetworkConfig { flags.disable_udp_hole_punching = disable_udp_hole_punching; } + if let Some(disable_upnp) = self.disable_upnp { + flags.disable_upnp = disable_upnp; + } + if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching { flags.disable_sym_hole_punching = disable_sym_hole_punching; } @@ -963,6 +967,7 @@ impl NetworkConfig { result.disable_encryption = Some(!flags.enable_encryption); result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching); result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching); + result.disable_upnp = Some(flags.disable_upnp); result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching); result.enable_magic_dns = Some(flags.accept_dns); result.mtu = Some(flags.mtu as i32); @@ -1230,6 +1235,7 @@ mod tests { flags.enable_encryption = rng.gen_bool(0.8); flags.disable_tcp_hole_punching = rng.gen_bool(0.2); flags.disable_udp_hole_punching = rng.gen_bool(0.2); + flags.disable_upnp = rng.gen_bool(0.2); flags.accept_dns = rng.gen_bool(0.6); flags.mtu = rng.gen_range(1200..1500); flags.private_mode = rng.gen_bool(0.3); diff --git a/easytier/src/peers/peer_rpc_service.rs b/easytier/src/peers/peer_rpc_service.rs index 729f5354..901e62aa 100644 --- a/easytier/src/peers/peer_rpc_service.rs +++ b/easytier/src/peers/peer_rpc_service.rs @@ -5,7 +5,7 @@ use crate::{ proto::{ common::Void, peer_rpc::{ - DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest, + DirectConnectorRpc, GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest, }, rpc_types::{self, controller::BaseController}, }, @@ -50,29 +50,29 @@ impl DirectConnectorRpc for DirectConnectorManagerRpcServer { Ok(ret) } - async fn send_v6_hole_punch_packet( + async fn send_udp_hole_punch_packet( &self, _: BaseController, - req: SendV6HolePunchPacketRequest, + req: SendUdpHolePunchPacketRequest, ) -> rpc_types::error::Result { let listener_port = req.listener_port as u16; - let SocketAddr::V6(connector_addr) = req + let connector_addr: SocketAddr = req .connector_addr .ok_or(anyhow::anyhow!("connector_addr is required"))? - .into() - else { - return Err(anyhow::anyhow!("connector_addr is not a v6 address").into()); - }; + .into(); tracing::info!( - "Sending v6 hole punch packet to {} from listener port {}", + "Sending udp hole punch packet to {} from listener port {}", connector_addr, listener_port ); // send 3 packets to the connector for _ in 0..3 { - udp::send_v6_hole_punch_packet(listener_port, connector_addr).await?; + match connector_addr { + 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).await?, + } tokio::time::sleep(std::time::Duration::from_millis(30)).await; } Ok(Default::default()) diff --git a/easytier/src/proto/api_manage.proto b/easytier/src/proto/api_manage.proto index 0641672f..ab8a648c 100644 --- a/easytier/src/proto/api_manage.proto +++ b/easytier/src/proto/api_manage.proto @@ -89,6 +89,7 @@ message NetworkConfig { optional bool lazy_p2p = 58; optional bool need_p2p = 59; optional uint64 instance_recv_bps_limit = 60; + optional bool disable_upnp = 61; } message PortForwardConfig { diff --git a/easytier/src/proto/common.proto b/easytier/src/proto/common.proto index 91e33d35..58d105a7 100644 --- a/easytier/src/proto/common.proto +++ b/easytier/src/proto/common.proto @@ -74,6 +74,7 @@ message FlagsInConfig { bool lazy_p2p = 37; bool need_p2p = 38; uint64 instance_recv_bps_limit = 39; + bool disable_upnp = 40; } message RpcDescriptor { diff --git a/easytier/src/proto/peer_rpc.proto b/easytier/src/proto/peer_rpc.proto index 5ededd12..f3dfde6c 100644 --- a/easytier/src/proto/peer_rpc.proto +++ b/easytier/src/proto/peer_rpc.proto @@ -142,18 +142,19 @@ message GetIpListResponse { repeated common.Url listeners = 5; } -message SendV6HolePunchPacketRequest { +message SendUdpHolePunchPacketRequest { common.SocketAddr connector_addr = 1; uint32 listener_port = 2; } service DirectConnectorRpc { rpc GetIpList(GetIpListRequest) returns (GetIpListResponse); - rpc SendV6HolePunchPacket(SendV6HolePunchPacketRequest) returns (common.Void); + rpc SendUdpHolePunchPacket(SendUdpHolePunchPacketRequest) returns (common.Void); } message SelectPunchListenerRequest { bool force_new = 1; + bool prefer_port_mapping = 2; } message SelectPunchListenerResponse { diff --git a/easytier/src/tests/mod.rs b/easytier/src/tests/mod.rs index 4c983252..893befaf 100644 --- a/easytier/src/tests/mod.rs +++ b/easytier/src/tests/mod.rs @@ -6,6 +6,9 @@ mod ipv6_test; #[cfg(target_os = "linux")] mod credential_tests; +#[cfg(target_os = "linux")] +mod upnp_test; + use crate::common::PeerId; use crate::peers::peer_manager::PeerManager; diff --git a/easytier/src/tests/upnp_test.rs b/easytier/src/tests/upnp_test.rs new file mode 100644 index 00000000..8e12c72c --- /dev/null +++ b/easytier/src/tests/upnp_test.rs @@ -0,0 +1,1952 @@ +use std::{ + ffi::OsStr, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context, anyhow, bail}; +use igd_next::{ + GetGenericPortMappingEntryError, PortMappingEntry, PortMappingProtocol, SearchOptions, + aio::tokio::search_gateway, +}; +use tempfile::TempDir; +use tokio::net::UdpSocket; + +use super::{create_netns, del_netns, drop_insts, get_host_veth_name, ping_test}; +use crate::{ + common::{ + config::{ConfigLoader, TomlConfigLoader}, + error::Error, + global_ctx::{GlobalCtx, GlobalCtxEvent}, + netns::NetNS, + stun::{MockStunInfoCollector, StunInfoCollectorTrait}, + }, + connector::udp_hole_punch::{UdpHolePunchConnector, common::UdpHolePunchListener}, + instance::instance::Instance, + peers::{ + create_packet_recv_chan, + peer_manager::{PeerManager, RouteAlgoType}, + tests::{connect_peer_manager, wait_route_appear, wait_route_appear_with_cost}, + }, + proto::common::{NatType, StunInfo}, + tunnel::{common::tests::wait_for_condition, ring::RingTunnelConnector}, +}; + +const TEST_NS_A: &str = "upnp_a"; +const TEST_NS_C: &str = "upnp_c"; +const TEST_BRIDGE: &str = "br_upnp"; +const TEST_WAN_IF: &str = "upnp_wan0"; +const TEST_GATEWAY_IP: Ipv4Addr = Ipv4Addr::new(172, 31, 255, 1); +const TEST_CLIENT_A_IP: Ipv4Addr = Ipv4Addr::new(172, 31, 255, 2); +const TEST_CLIENT_C_IP: Ipv4Addr = Ipv4Addr::new(172, 31, 255, 3); +const TEST_EXTERNAL_IP: Ipv4Addr = Ipv4Addr::new(11, 22, 33, 44); +const TEST_CONTROL_PORT: u16 = 5000; +const TEST_IGD_DESCRIPTION: &str = "EasyTier udp hole punch"; + +const DUAL_NS_A: &str = "upnp2_a"; +const DUAL_NS_C: &str = "upnp2_c"; +const DUAL_LAN_A_BRIDGE: &str = "br_upnp2_a"; +const DUAL_LAN_C_BRIDGE: &str = "br_upnp2_c"; +const DUAL_WAN_BRIDGE: &str = "br_upnp2_wan"; +const DUAL_GATEWAY_A_IP: Ipv4Addr = Ipv4Addr::new(172, 30, 1, 1); +const DUAL_GATEWAY_C_IP: Ipv4Addr = Ipv4Addr::new(172, 30, 2, 1); +const DUAL_CLIENT_A_IP: Ipv4Addr = Ipv4Addr::new(172, 30, 1, 2); +const DUAL_CLIENT_C_IP: Ipv4Addr = Ipv4Addr::new(172, 30, 2, 2); +const DUAL_EXTERNAL_A_IP: Ipv4Addr = Ipv4Addr::new(11, 22, 33, 1); +const DUAL_EXTERNAL_C_IP: Ipv4Addr = Ipv4Addr::new(11, 22, 33, 2); +const DUAL_GATEWAY_A_PORT: u16 = 5001; +const DUAL_GATEWAY_C_PORT: u16 = 5002; +const DUAL_WAN_IF_A: &str = "upnp2_wan_a"; +const DUAL_WAN_IF_A_PEER: &str = "upnp2_wan_a_p"; +const DUAL_WAN_IF_C: &str = "upnp2_wan_c"; +const DUAL_WAN_IF_C_PEER: &str = "upnp2_wan_c_p"; +const DUAL_GW_NS_A: &str = "upnp2_gw_a"; +const DUAL_GW_NS_C: &str = "upnp2_gw_c"; + +struct UpnpIntegrationEnv { + _tempdir: TempDir, + child: Option, +} + +impl UpnpIntegrationEnv { + async fn new() -> anyhow::Result { + cleanup_miniupnpd_processes(); + cleanup_test_net(); + create_test_net()?; + + let tempdir = tempfile::tempdir().context("create miniupnpd tempdir")?; + let conf_path = tempdir.path().join("miniupnpd.conf"); + let leases_path = tempdir.path().join("miniupnpd.leases"); + std::fs::write(&leases_path, "").context("create miniupnpd lease file")?; + std::fs::write( + &conf_path, + format!( + "\ +ext_ifname={TEST_WAN_IF} +listening_ip={TEST_BRIDGE} +port={TEST_CONTROL_PORT} +enable_natpmp=no +enable_upnp=yes +secure_mode=no +system_uptime=yes +lease_file={} +ext_ip={} +friendly_name=EasyTier Test IGD +model_name=EasyTier Test +serial=12345678 +uuid=9f0c5a3a-c4f0-4f1e-b4df-8a8c7b1e2d00 +allow 1024-65535 172.31.255.0/24 1024-65535 +deny 0-65535 0.0.0.0/0 0-65535 +", + leases_path.display(), + TEST_EXTERNAL_IP + ), + ) + .context("write miniupnpd config")?; + + let miniupnpd_bin = find_miniupnpd_bin()?; + let child = Command::new(miniupnpd_bin) + .args(["-d", "-f"]) + .arg(&conf_path) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("spawn miniupnpd")?; + + let env = Self { + _tempdir: tempdir, + child: Some(child), + }; + env.wait_ready().await?; + Ok(env) + } + + async fn wait_ready(&self) -> anyhow::Result<()> { + wait_for_condition( + || async { + tokio::net::TcpStream::connect((TEST_GATEWAY_IP, TEST_CONTROL_PORT)) + .await + .is_ok() + }, + Duration::from_secs(10), + ) + .await; + Ok(()) + } +} + +impl Drop for UpnpIntegrationEnv { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + cleanup_miniupnpd_processes(); + cleanup_test_net(); + } +} + +struct DualGatewayUpnpIntegrationEnv { + _tempdir: TempDir, + children: Vec, +} + +struct DualGatewayConfig<'a> { + name: &'a str, + ext_if: &'a str, + listening_ip: &'a str, + port: u16, + ext_ip: Ipv4Addr, + uuid: &'a str, + chain_name: &'a str, + postrouting_chain_name: &'a str, + allow_subnet: &'a str, +} + +impl DualGatewayUpnpIntegrationEnv { + async fn new() -> anyhow::Result { + cleanup_miniupnpd_processes(); + cleanup_dual_gateway_test_net(); + create_dual_gateway_test_net()?; + + let tempdir = tempfile::tempdir().context("create dual miniupnpd tempdir")?; + let gateway_a = write_dual_gateway_config( + tempdir.path(), + DualGatewayConfig { + name: "gateway_a", + ext_if: DUAL_WAN_IF_A, + listening_ip: DUAL_LAN_A_BRIDGE, + port: DUAL_GATEWAY_A_PORT, + ext_ip: DUAL_EXTERNAL_A_IP, + uuid: "9f0c5a3a-c4f0-4f1e-b4df-8a8c7b1e2d01", + chain_name: "MINIUPNPD_A", + postrouting_chain_name: "MINIUPNPD_A-POSTROUTING", + allow_subnet: "172.30.1.0/24", + }, + )?; + let gateway_c = write_dual_gateway_config( + tempdir.path(), + DualGatewayConfig { + name: "gateway_c", + ext_if: DUAL_WAN_IF_C, + listening_ip: DUAL_LAN_C_BRIDGE, + port: DUAL_GATEWAY_C_PORT, + ext_ip: DUAL_EXTERNAL_C_IP, + uuid: "9f0c5a3a-c4f0-4f1e-b4df-8a8c7b1e2d02", + chain_name: "MINIUPNPD_C", + postrouting_chain_name: "MINIUPNPD_C-POSTROUTING", + allow_subnet: "172.30.2.0/24", + }, + )?; + let pid_a = tempdir.path().join("gateway_a.pid"); + let pid_c = tempdir.path().join("gateway_c.pid"); + + let miniupnpd_bin = find_miniupnpd_bin()?; + let miniupnpd_bin_str = miniupnpd_bin + .to_str() + .ok_or_else(|| anyhow!("non-utf8 miniupnpd path: {}", miniupnpd_bin.display()))?; + let pid_a_str = pid_a + .to_str() + .ok_or_else(|| anyhow!("non-utf8 pid path: {}", pid_a.display()))?; + let pid_c_str = pid_c + .to_str() + .ok_or_else(|| anyhow!("non-utf8 pid path: {}", pid_c.display()))?; + let child_a = Command::new("ip") + .args([ + "netns", + "exec", + DUAL_GW_NS_A, + miniupnpd_bin_str, + "-d", + "-P", + pid_a_str, + "-f", + ]) + .arg(&gateway_a) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("spawn gateway a miniupnpd")?; + let child_c = Command::new("ip") + .args([ + "netns", + "exec", + DUAL_GW_NS_C, + miniupnpd_bin_str, + "-d", + "-P", + pid_c_str, + "-f", + ]) + .arg(&gateway_c) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("spawn gateway c miniupnpd")?; + + let env = Self { + _tempdir: tempdir, + children: vec![child_a, child_c], + }; + env.wait_ready().await?; + Ok(env) + } + + async fn wait_ready(&self) -> anyhow::Result<()> { + wait_for_condition( + || async { + tcp_connect_in_ns(DUAL_NS_A, DUAL_GATEWAY_A_IP, DUAL_GATEWAY_A_PORT) + .await + .is_ok() + && tcp_connect_in_ns(DUAL_NS_C, DUAL_GATEWAY_C_IP, DUAL_GATEWAY_C_PORT) + .await + .is_ok() + }, + Duration::from_secs(10), + ) + .await; + Ok(()) + } +} + +impl Drop for DualGatewayUpnpIntegrationEnv { + fn drop(&mut self) { + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + cleanup_miniupnpd_processes(); + cleanup_dual_gateway_test_net(); + } +} + +#[derive(Clone)] +struct GatewayBackedStunCollector { + netns: &'static str, + client_ip: Ipv4Addr, + external_ip: Ipv4Addr, +} + +#[async_trait::async_trait] +impl StunInfoCollectorTrait for GatewayBackedStunCollector { + fn get_stun_info(&self) -> StunInfo { + StunInfo { + udp_nat_type: NatType::PortRestricted as i32, + tcp_nat_type: NatType::Unknown as i32, + last_update_time: 0, + min_port: 0, + max_port: 0, + public_ip: vec![self.external_ip.to_string()], + } + } + + async fn get_udp_port_mapping(&self, local_port: u16) -> Result { + query_udp_mapping(self.netns, self.external_ip, self.client_ip, local_port).await + } + + async fn get_udp_port_mapping_with_socket( + &self, + udp: Arc, + ) -> Result { + query_udp_mapping( + self.netns, + self.external_ip, + self.client_ip, + udp.local_addr()?.port(), + ) + .await + } + + async fn get_tcp_port_mapping(&self, local_port: u16) -> Result { + Ok(SocketAddr::new(IpAddr::V4(self.external_ip), local_port)) + } +} + +fn create_test_net() -> anyhow::Result<()> { + create_netns(TEST_NS_A, &format!("{TEST_CLIENT_A_IP}/24"), "fd10::2/64"); + create_netns(TEST_NS_C, &format!("{TEST_CLIENT_C_IP}/24"), "fd10::3/64"); + run_cmd( + "ip", + &["link", "add", "name", TEST_BRIDGE, "type", "bridge"], + )?; + for netns in [TEST_NS_A, TEST_NS_C] { + run_cmd( + "ip", + &[ + "link", + "set", + get_host_veth_name(netns), + "master", + TEST_BRIDGE, + ], + )?; + } + + run_cmd( + "ip", + &[ + "addr", + "add", + &format!("{TEST_GATEWAY_IP}/24"), + "dev", + TEST_BRIDGE, + ], + )?; + run_cmd("ip", &["link", "add", TEST_WAN_IF, "type", "dummy"])?; + run_cmd( + "ip", + &[ + "addr", + "add", + &format!("{TEST_EXTERNAL_IP}/24"), + "dev", + TEST_WAN_IF, + ], + )?; + run_cmd("ip", &["link", "set", TEST_WAN_IF, "up"])?; + run_cmd("ip", &["link", "set", TEST_BRIDGE, "up"])?; + setup_iptables_rules()?; + for (netns, guest_veth) in [(TEST_NS_A, "veth_upnp_a_g"), (TEST_NS_C, "veth_upnp_c_g")] { + run_cmd( + "ip", + &[ + "netns", + "exec", + netns, + "ip", + "route", + "add", + "default", + "via", + &TEST_GATEWAY_IP.to_string(), + "dev", + guest_veth, + ], + )?; + } + run_cmd("sysctl", &["-w", "net.ipv4.ip_forward=1"])?; + Ok(()) +} + +fn cleanup_test_net() { + cleanup_iptables_rules(); + del_netns(TEST_NS_A); + del_netns(TEST_NS_C); + let _ = Command::new("ip") + .args(["link", "del", TEST_BRIDGE]) + .output(); + let _ = Command::new("ip") + .args(["link", "del", TEST_WAN_IF]) + .output(); +} + +fn write_dual_gateway_config(dir: &Path, config: DualGatewayConfig<'_>) -> anyhow::Result { + let conf_path = dir.join(format!("{}.conf", config.name)); + let lease_path = dir.join(format!("{}.leases", config.name)); + std::fs::write(&lease_path, "") + .with_context(|| format!("create lease file for {}", config.name))?; + std::fs::write( + &conf_path, + format!( + "\ +ext_ifname={ext_if} +listening_ip={listening_ip} +port={port} +enable_natpmp=no +enable_upnp=yes +secure_mode=no +system_uptime=yes +lease_file={} +ext_ip={ext_ip} +friendly_name=EasyTier Test IGD {name} +model_name=EasyTier Test +serial=12345678 +uuid={uuid} +upnp_forward_chain={chain_name} +upnp_nat_chain={chain_name} +upnp_nat_postrouting_chain={postrouting_chain_name} +allow 1024-65535 {allow_subnet} 1024-65535 +deny 0-65535 0.0.0.0/0 0-65535 +", + lease_path.display(), + ext_if = config.ext_if, + listening_ip = config.listening_ip, + port = config.port, + ext_ip = config.ext_ip, + name = config.name, + uuid = config.uuid, + chain_name = config.chain_name, + postrouting_chain_name = config.postrouting_chain_name, + allow_subnet = config.allow_subnet, + ), + ) + .with_context(|| format!("write config for {}", config.name))?; + Ok(conf_path) +} + +async fn tcp_connect_in_ns(netns: &'static str, ip: Ipv4Addr, port: u16) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || { + let _g = NetNS::new(Some(netns.to_owned())).guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for tcp connect in netns")? + .block_on(async move { + tokio::net::TcpStream::connect((ip, port)) + .await + .map(|_| ()) + .context("tcp connect in netns") + }) + }) + .await + .context("join tcp connect in netns task")? +} + +fn create_empty_netns(name: &str) -> anyhow::Result<()> { + run_cmd("ip", &["netns", "add", name])?; + run_cmd( + "ip", + &["netns", "exec", name, "ip", "link", "set", "lo", "up"], + )?; + Ok(()) +} + +fn run_cmd_in_netns>(netns: &str, cmd: S, args: &[&str]) -> anyhow::Result<()> { + let cmd = cmd.as_ref(); + let cmd_str = cmd + .to_str() + .ok_or_else(|| anyhow!("non-utf8 command path: {}", Path::new(cmd).display()))?; + let mut full_args = vec!["netns", "exec", netns, cmd_str]; + full_args.extend_from_slice(args); + run_cmd("ip", &full_args) +} + +fn create_dual_gateway_test_net() -> anyhow::Result<()> { + create_netns(DUAL_NS_A, &format!("{DUAL_CLIENT_A_IP}/24"), "fd20::2/64"); + create_netns(DUAL_NS_C, &format!("{DUAL_CLIENT_C_IP}/24"), "fd21::2/64"); + create_empty_netns(DUAL_GW_NS_A)?; + create_empty_netns(DUAL_GW_NS_C)?; + + run_cmd( + "ip", + &[ + "link", + "set", + get_host_veth_name(DUAL_NS_A), + "netns", + DUAL_GW_NS_A, + ], + )?; + run_cmd( + "ip", + &[ + "link", + "set", + get_host_veth_name(DUAL_NS_C), + "netns", + DUAL_GW_NS_C, + ], + )?; + + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &["link", "add", "name", DUAL_LAN_A_BRIDGE, "type", "bridge"], + )?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &["link", "add", "name", DUAL_LAN_C_BRIDGE, "type", "bridge"], + )?; + run_cmd( + "ip", + &["link", "add", "name", DUAL_WAN_BRIDGE, "type", "bridge"], + )?; + + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &[ + "link", + "set", + get_host_veth_name(DUAL_NS_A), + "master", + DUAL_LAN_A_BRIDGE, + ], + )?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &[ + "link", + "set", + get_host_veth_name(DUAL_NS_C), + "master", + DUAL_LAN_C_BRIDGE, + ], + )?; + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &[ + "addr", + "add", + &format!("{DUAL_GATEWAY_A_IP}/24"), + "dev", + DUAL_LAN_A_BRIDGE, + ], + )?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &[ + "addr", + "add", + &format!("{DUAL_GATEWAY_C_IP}/24"), + "dev", + DUAL_LAN_C_BRIDGE, + ], + )?; + + run_cmd( + "ip", + &[ + "link", + "add", + DUAL_WAN_IF_A, + "type", + "veth", + "peer", + "name", + DUAL_WAN_IF_A_PEER, + ], + )?; + run_cmd( + "ip", + &[ + "link", + "add", + DUAL_WAN_IF_C, + "type", + "veth", + "peer", + "name", + DUAL_WAN_IF_C_PEER, + ], + )?; + run_cmd("ip", &["link", "set", DUAL_WAN_IF_A, "netns", DUAL_GW_NS_A])?; + run_cmd("ip", &["link", "set", DUAL_WAN_IF_C, "netns", DUAL_GW_NS_C])?; + run_cmd( + "ip", + &["link", "set", DUAL_WAN_IF_A_PEER, "master", DUAL_WAN_BRIDGE], + )?; + run_cmd( + "ip", + &["link", "set", DUAL_WAN_IF_C_PEER, "master", DUAL_WAN_BRIDGE], + )?; + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &[ + "addr", + "add", + &format!("{DUAL_EXTERNAL_A_IP}/24"), + "dev", + DUAL_WAN_IF_A, + ], + )?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &[ + "addr", + "add", + &format!("{DUAL_EXTERNAL_C_IP}/24"), + "dev", + DUAL_WAN_IF_C, + ], + )?; + run_cmd("ip", &["link", "set", DUAL_WAN_BRIDGE, "up"])?; + run_cmd("ip", &["link", "set", DUAL_WAN_IF_A_PEER, "up"])?; + run_cmd("ip", &["link", "set", DUAL_WAN_IF_C_PEER, "up"])?; + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &["link", "set", DUAL_LAN_A_BRIDGE, "up"], + )?; + run_cmd_in_netns( + DUAL_GW_NS_A, + "ip", + &["link", "set", get_host_veth_name(DUAL_NS_A), "up"], + )?; + run_cmd_in_netns(DUAL_GW_NS_A, "ip", &["link", "set", DUAL_WAN_IF_A, "up"])?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &["link", "set", DUAL_LAN_C_BRIDGE, "up"], + )?; + run_cmd_in_netns( + DUAL_GW_NS_C, + "ip", + &["link", "set", get_host_veth_name(DUAL_NS_C), "up"], + )?; + run_cmd_in_netns(DUAL_GW_NS_C, "ip", &["link", "set", DUAL_WAN_IF_C, "up"])?; + + setup_gateway_iptables_rules_in_netns( + DUAL_GW_NS_A, + DUAL_WAN_IF_A, + DUAL_LAN_A_BRIDGE, + "MINIUPNPD_A", + "MINIUPNPD_A-POSTROUTING", + )?; + setup_gateway_iptables_rules_in_netns( + DUAL_GW_NS_C, + DUAL_WAN_IF_C, + DUAL_LAN_C_BRIDGE, + "MINIUPNPD_C", + "MINIUPNPD_C-POSTROUTING", + )?; + + run_cmd( + "ip", + &[ + "netns", + "exec", + DUAL_NS_A, + "ip", + "route", + "add", + "default", + "via", + &DUAL_GATEWAY_A_IP.to_string(), + "dev", + "veth_upnp2_a_g", + ], + )?; + run_cmd( + "ip", + &[ + "netns", + "exec", + DUAL_NS_C, + "ip", + "route", + "add", + "default", + "via", + &DUAL_GATEWAY_C_IP.to_string(), + "dev", + "veth_upnp2_c_g", + ], + )?; + run_cmd_in_netns(DUAL_GW_NS_A, "sysctl", &["-w", "net.ipv4.ip_forward=1"])?; + run_cmd_in_netns(DUAL_GW_NS_C, "sysctl", &["-w", "net.ipv4.ip_forward=1"])?; + Ok(()) +} + +fn cleanup_dual_gateway_test_net() { + cleanup_gateway_iptables_rules_in_netns( + DUAL_GW_NS_A, + DUAL_WAN_IF_A, + DUAL_LAN_A_BRIDGE, + "MINIUPNPD_A", + "MINIUPNPD_A-POSTROUTING", + ); + cleanup_gateway_iptables_rules_in_netns( + DUAL_GW_NS_C, + DUAL_WAN_IF_C, + DUAL_LAN_C_BRIDGE, + "MINIUPNPD_C", + "MINIUPNPD_C-POSTROUTING", + ); + + del_netns(DUAL_NS_A); + del_netns(DUAL_NS_C); + del_netns(DUAL_GW_NS_A); + del_netns(DUAL_GW_NS_C); + for iface in [DUAL_WAN_BRIDGE, DUAL_WAN_IF_A_PEER, DUAL_WAN_IF_C_PEER] { + let _ = Command::new("ip").args(["link", "del", iface]).output(); + } +} + +fn cleanup_miniupnpd_processes() { + let _ = Command::new("pkill").args(["-x", "miniupnpd"]).output(); +} + +fn setup_gateway_iptables_rules( + ext_if: &str, + lan_bridge: &str, + chain_name: &str, + postrouting_chain_name: &str, +) -> anyhow::Result<()> { + cleanup_gateway_iptables_rules(ext_if, lan_bridge, chain_name, postrouting_chain_name); + let iptables = find_iptables_legacy_bin()?; + + run_cmd(&iptables, &["-t", "nat", "-N", chain_name])?; + run_cmd( + &iptables, + &[ + "-t", + "nat", + "-A", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + )?; + run_cmd(&iptables, &["-t", "nat", "-N", postrouting_chain_name])?; + run_cmd( + &iptables, + &[ + "-t", + "nat", + "-A", + "POSTROUTING", + "-o", + ext_if, + "-j", + postrouting_chain_name, + ], + )?; + run_cmd(&iptables, &["-t", "mangle", "-N", chain_name])?; + run_cmd( + &iptables, + &[ + "-t", + "mangle", + "-A", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + )?; + run_cmd(&iptables, &["-N", chain_name])?; + run_cmd( + &iptables, + &[ + "-A", "FORWARD", "-i", ext_if, "!", "-o", ext_if, "-j", chain_name, + ], + )?; + run_cmd( + &iptables, + &[ + "-A", "FORWARD", "-i", lan_bridge, "-o", ext_if, "-j", "ACCEPT", + ], + )?; + Ok(()) +} + +fn cleanup_gateway_iptables_rules( + ext_if: &str, + lan_bridge: &str, + chain_name: &str, + postrouting_chain_name: &str, +) { + let Ok(iptables) = find_iptables_legacy_bin() else { + return; + }; + + let _ = Command::new(&iptables) + .args([ + "-t", + "nat", + "-D", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-t", + "nat", + "-D", + "POSTROUTING", + "-o", + ext_if, + "-j", + postrouting_chain_name, + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-t", + "mangle", + "-D", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-D", "FORWARD", "-i", ext_if, "!", "-o", ext_if, "-j", chain_name, + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-D", "FORWARD", "-i", lan_bridge, "-o", ext_if, "-j", "ACCEPT", + ]) + .output(); + let _ = Command::new(&iptables).args(["-F", chain_name]).output(); + let _ = Command::new(&iptables).args(["-X", chain_name]).output(); + let _ = Command::new(&iptables) + .args(["-t", "mangle", "-F", chain_name]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "mangle", "-X", chain_name]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-F", chain_name]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-X", chain_name]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-F", postrouting_chain_name]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-X", postrouting_chain_name]) + .output(); +} + +fn setup_gateway_iptables_rules_in_netns( + netns: &str, + ext_if: &str, + lan_bridge: &str, + chain_name: &str, + postrouting_chain_name: &str, +) -> anyhow::Result<()> { + cleanup_gateway_iptables_rules_in_netns( + netns, + ext_if, + lan_bridge, + chain_name, + postrouting_chain_name, + ); + let iptables = find_iptables_legacy_bin()?; + + run_cmd_in_netns(netns, &iptables, &["-t", "nat", "-N", chain_name])?; + run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "nat", + "-A", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + )?; + run_cmd_in_netns( + netns, + &iptables, + &["-t", "nat", "-N", postrouting_chain_name], + )?; + run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "nat", + "-A", + "POSTROUTING", + "-o", + ext_if, + "-j", + postrouting_chain_name, + ], + )?; + run_cmd_in_netns(netns, &iptables, &["-t", "mangle", "-N", chain_name])?; + run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "mangle", + "-A", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + )?; + run_cmd_in_netns(netns, &iptables, &["-N", chain_name])?; + run_cmd_in_netns( + netns, + &iptables, + &[ + "-A", "FORWARD", "-i", ext_if, "!", "-o", ext_if, "-j", chain_name, + ], + )?; + run_cmd_in_netns( + netns, + &iptables, + &[ + "-A", "FORWARD", "-i", lan_bridge, "-o", ext_if, "-j", "ACCEPT", + ], + )?; + Ok(()) +} + +fn cleanup_gateway_iptables_rules_in_netns( + netns: &str, + ext_if: &str, + lan_bridge: &str, + chain_name: &str, + postrouting_chain_name: &str, +) { + let Ok(iptables) = find_iptables_legacy_bin() else { + return; + }; + + let _ = run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "nat", + "-D", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + ); + let _ = run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "nat", + "-D", + "POSTROUTING", + "-o", + ext_if, + "-j", + postrouting_chain_name, + ], + ); + let _ = run_cmd_in_netns( + netns, + &iptables, + &[ + "-t", + "mangle", + "-D", + "PREROUTING", + "-i", + ext_if, + "-j", + chain_name, + ], + ); + let _ = run_cmd_in_netns( + netns, + &iptables, + &[ + "-D", "FORWARD", "-i", ext_if, "!", "-o", ext_if, "-j", chain_name, + ], + ); + let _ = run_cmd_in_netns( + netns, + &iptables, + &[ + "-D", "FORWARD", "-i", lan_bridge, "-o", ext_if, "-j", "ACCEPT", + ], + ); + let _ = run_cmd_in_netns(netns, &iptables, &["-F", chain_name]); + let _ = run_cmd_in_netns(netns, &iptables, &["-X", chain_name]); + let _ = run_cmd_in_netns(netns, &iptables, &["-t", "mangle", "-F", chain_name]); + let _ = run_cmd_in_netns(netns, &iptables, &["-t", "mangle", "-X", chain_name]); + let _ = run_cmd_in_netns(netns, &iptables, &["-t", "nat", "-F", chain_name]); + let _ = run_cmd_in_netns(netns, &iptables, &["-t", "nat", "-X", chain_name]); + let _ = run_cmd_in_netns( + netns, + &iptables, + &["-t", "nat", "-F", postrouting_chain_name], + ); + let _ = run_cmd_in_netns( + netns, + &iptables, + &["-t", "nat", "-X", postrouting_chain_name], + ); +} + +fn setup_iptables_rules() -> anyhow::Result<()> { + cleanup_iptables_rules(); + let iptables = find_iptables_legacy_bin()?; + + run_cmd(&iptables, &["-t", "nat", "-N", "MINIUPNPD"])?; + run_cmd( + &iptables, + &[ + "-t", + "nat", + "-A", + "PREROUTING", + "-i", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ], + )?; + run_cmd(&iptables, &["-t", "nat", "-N", "MINIUPNPD-POSTROUTING"])?; + run_cmd( + &iptables, + &[ + "-t", + "nat", + "-A", + "POSTROUTING", + "-o", + TEST_WAN_IF, + "-j", + "MINIUPNPD-POSTROUTING", + ], + )?; + + run_cmd(&iptables, &["-t", "mangle", "-N", "MINIUPNPD"])?; + run_cmd( + &iptables, + &[ + "-t", + "mangle", + "-A", + "PREROUTING", + "-i", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ], + )?; + + run_cmd(&iptables, &["-N", "MINIUPNPD"])?; + run_cmd( + &iptables, + &[ + "-A", + "FORWARD", + "-i", + TEST_WAN_IF, + "!", + "-o", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ], + )?; + run_cmd( + &iptables, + &[ + "-A", + "FORWARD", + "-i", + TEST_BRIDGE, + "-o", + TEST_WAN_IF, + "-j", + "ACCEPT", + ], + )?; + + Ok(()) +} + +fn cleanup_iptables_rules() { + let Ok(iptables) = find_iptables_legacy_bin() else { + return; + }; + + let _ = Command::new(&iptables) + .args([ + "-t", + "nat", + "-D", + "PREROUTING", + "-i", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-t", + "nat", + "-D", + "POSTROUTING", + "-o", + TEST_WAN_IF, + "-j", + "MINIUPNPD-POSTROUTING", + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-t", + "mangle", + "-D", + "PREROUTING", + "-i", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-D", + "FORWARD", + "-i", + TEST_WAN_IF, + "!", + "-o", + TEST_WAN_IF, + "-j", + "MINIUPNPD", + ]) + .output(); + let _ = Command::new(&iptables) + .args([ + "-D", + "FORWARD", + "-i", + TEST_BRIDGE, + "-o", + TEST_WAN_IF, + "-j", + "ACCEPT", + ]) + .output(); + let _ = Command::new(&iptables).args(["-F", "MINIUPNPD"]).output(); + let _ = Command::new(&iptables).args(["-X", "MINIUPNPD"]).output(); + let _ = Command::new(&iptables) + .args(["-t", "mangle", "-F", "MINIUPNPD"]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "mangle", "-X", "MINIUPNPD"]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-F", "MINIUPNPD"]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-X", "MINIUPNPD"]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-F", "MINIUPNPD-POSTROUTING"]) + .output(); + let _ = Command::new(&iptables) + .args(["-t", "nat", "-X", "MINIUPNPD-POSTROUTING"]) + .output(); +} + +fn run_cmd>(cmd: S, args: &[&str]) -> anyhow::Result<()> { + let cmd = cmd.as_ref(); + let output = Command::new(cmd) + .args(args) + .output() + .with_context(|| format!("run command {}", Path::new(cmd).display()))?; + if output.status.success() { + return Ok(()); + } + + Err(anyhow!( + "{} {:?} failed: stdout={}, stderr={}", + Path::new(cmd).display(), + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) +} + +fn find_miniupnpd_bin() -> anyhow::Result { + for candidate in ["miniupnpd", "/usr/sbin/miniupnpd", "/sbin/miniupnpd"] { + let path = Path::new(candidate); + if candidate.contains('/') { + if path.exists() { + return Ok(path.to_path_buf()); + } + continue; + } + + if Command::new("sh") + .args(["-c", &format!("command -v {candidate}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return Ok(path.to_path_buf()); + } + } + + bail!("miniupnpd binary not found; install miniupnpd and miniupnpd-iptables") +} + +fn find_iptables_legacy_bin() -> anyhow::Result { + for candidate in [ + "/usr/sbin/iptables-legacy", + "/sbin/iptables-legacy", + "iptables-legacy", + ] { + let path = Path::new(candidate); + if candidate.contains('/') { + if path.exists() { + return Ok(path.to_path_buf()); + } + continue; + } + + if Command::new("sh") + .args(["-c", &format!("command -v {candidate}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return Ok(path.to_path_buf()); + } + } + + bail!("iptables-legacy binary not found; install iptables legacy backend") +} + +async fn query_mappings( + query_netns: &'static str, + expected_external_ip: Ipv4Addr, +) -> Result, Error> { + tokio::task::spawn_blocking(move || { + let _g = NetNS::new(Some(query_netns.to_owned())).guard(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build runtime for test igd query")? + .block_on(async move { + let gateway = search_gateway(SearchOptions { + timeout: Some(Duration::from_secs(1)), + single_search_timeout: Some(Duration::from_millis(300)), + ..Default::default() + }) + .await + .context("discover igd gateway")?; + + let external_ip = + match gateway.get_external_ip().await.context("get external ip")? { + IpAddr::V4(ip) => ip, + ip => return Err(anyhow!("unexpected external ip family: {ip}")), + }; + + let mut entries = Vec::new(); + for index in 0..64 { + match gateway.get_generic_port_mapping_entry(index).await { + Ok(entry) => { + if entry.protocol == PortMappingProtocol::UDP + && entry.port_mapping_description == TEST_IGD_DESCRIPTION + { + entries.push(entry); + } + } + Err(GetGenericPortMappingEntryError::SpecifiedArrayIndexInvalid) => break, + Err(err) => return Err(anyhow!(err.to_string())), + } + } + + if external_ip != expected_external_ip { + return Err(anyhow!( + "unexpected external ip from gateway: expected {expected_external_ip}, got {external_ip}" + )); + } + + Ok(entries) + }) + }) + .await + .context("join test igd query task") + .map_err(Error::from)? + .map_err(Error::from) +} + +async fn query_udp_mapping( + query_netns: &'static str, + expected_external_ip: Ipv4Addr, + client_ip: Ipv4Addr, + local_port: u16, +) -> Result { + let entries = query_mappings(query_netns, expected_external_ip).await?; + let client_ip = client_ip.to_string(); + let entry = entries + .into_iter() + .find(|entry| entry.internal_client == client_ip && entry.internal_port == local_port) + .ok_or_else(|| Error::from(anyhow!("udp mapping not found for local port {local_port}")))?; + Ok(SocketAddr::new( + IpAddr::V4(expected_external_ip), + entry.external_port, + )) +} + +async fn mapping_exists(local_port: u16) -> bool { + query_udp_mapping(TEST_NS_A, TEST_EXTERNAL_IP, TEST_CLIENT_A_IP, local_port) + .await + .is_ok() +} + +async fn create_test_peer_manager( + inst_name: &str, + netns: Option<&str>, + disable_upnp: bool, + stun_collector: Box, +) -> Arc { + let config = TomlConfigLoader::default(); + config.set_inst_name(inst_name.to_owned()); + config.set_netns(netns.map(ToOwned::to_owned)); + + let global_ctx = Arc::new(GlobalCtx::new(config)); + if disable_upnp { + let mut flags = global_ctx.get_flags(); + flags.disable_upnp = true; + global_ctx.set_flags(flags); + } + global_ctx.replace_stun_info_collector(stun_collector); + + let (packet_tx, _packet_rx) = create_packet_recv_chan(); + let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, global_ctx, packet_tx)); + peer_mgr.run().await.unwrap(); + peer_mgr +} + +fn create_test_instance_config( + inst_name: &str, + netns: Option<&str>, + ipv4: &str, + ipv6: &str, +) -> TomlConfigLoader { + let config = TomlConfigLoader::default(); + config.set_inst_name(inst_name.to_owned()); + config.set_netns(netns.map(ToOwned::to_owned)); + config.set_ipv4(Some(ipv4.parse().unwrap())); + config.set_ipv6(Some(ipv6.parse().unwrap())); + config.set_listeners(vec!["udp://0.0.0.0:11010".parse().unwrap()]); + config +} + +fn create_test_instance( + inst_name: &str, + netns: Option<&str>, + ipv4: &str, + ipv6: &str, + stun_collector: Box, + configure_flags: impl FnOnce(&mut crate::common::config::Flags), +) -> Instance { + let config = create_test_instance_config(inst_name, netns, ipv4, ipv6); + let mut flags = config.get_flags(); + flags.disable_tcp_hole_punching = true; + configure_flags(&mut flags); + config.set_flags(flags); + + let instance = Instance::new(config); + instance + .get_global_ctx() + .replace_stun_info_collector(stun_collector); + instance +} + +async fn wait_for_port_mapping_event( + receiver: &mut tokio::sync::broadcast::Receiver, +) -> GlobalCtxEvent { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match receiver.recv().await.unwrap() { + event @ GlobalCtxEvent::ListenerPortMappingEstablished { .. } => return event, + _ => continue, + } + } + }) + .await + .expect("timed out waiting for port mapping event") +} + +async fn timeout_stage(stage: &str, duration: Duration, fut: F) -> T +where + F: std::future::Future, +{ + tokio::time::timeout(duration, fut) + .await + .unwrap_or_else(|_| panic!("timed out at stage: {stage}")) +} + +async fn peer_has_udp_conn_to_remote_addr( + peer_mgr: Arc, + peer_id: u32, + expected_remote_addr: SocketAddr, +) -> bool { + let Some(conns) = peer_mgr.get_peer_map().list_peer_conns(peer_id).await else { + return false; + }; + + conns.iter().any(|conn| { + let Some(tunnel) = conn.tunnel.as_ref() else { + return false; + }; + if tunnel.tunnel_type != "udp" { + return false; + } + + let Some(remote_addr) = tunnel.effective_remote_addr() else { + return false; + }; + let remote_addr: url::Url = remote_addr.clone().into(); + let Some(host) = remote_addr.host_str() else { + return false; + }; + let Ok(remote_ip) = host.parse::() else { + return false; + }; + + remote_ip == expected_remote_addr.ip() + && remote_addr.port() == Some(expected_remote_addr.port()) + }) +} + +#[tokio::test] +#[serial_test::serial(upnp)] +async fn udp_hole_punch_listener_establishes_upnp_mapping() { + let _env = UpnpIntegrationEnv::new().await.unwrap(); + let peer_mgr = create_test_peer_manager( + "upnp-test-listener", + Some(TEST_NS_A), + false, + Box::new(GatewayBackedStunCollector { + netns: TEST_NS_A, + client_ip: TEST_CLIENT_A_IP, + external_ip: TEST_EXTERNAL_IP, + }), + ) + .await; + let mut event_rx = peer_mgr.get_global_ctx().subscribe(); + + let listener = UdpHolePunchListener::new(peer_mgr.clone()).await.unwrap(); + let local_port = listener.get_socket().await.local_addr().unwrap().port(); + + let event = wait_for_port_mapping_event(&mut event_rx).await; + let mapped_addr = query_udp_mapping(TEST_NS_A, TEST_EXTERNAL_IP, TEST_CLIENT_A_IP, local_port) + .await + .unwrap(); + + match event { + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + let expected_external_ip = TEST_EXTERNAL_IP.to_string(); + assert_eq!(backend, "igd"); + assert_eq!(local_listener.scheme(), "udp"); + assert_eq!(local_listener.port(), Some(local_port)); + assert_eq!(mapped_listener.scheme(), "udp"); + assert_eq!( + mapped_listener.host_str(), + Some(expected_external_ip.as_str()) + ); + assert_eq!(mapped_listener.port(), Some(mapped_addr.port())); + } + other => panic!("unexpected event: {other:?}"), + } + + assert!(mapping_exists(local_port).await); + + drop(listener); + + wait_for_condition( + || async { !mapping_exists(local_port).await }, + Duration::from_secs(10), + ) + .await; +} + +#[tokio::test] +#[serial_test::serial(upnp)] +async fn udp_hole_punch_listener_skips_upnp_when_disabled() { + let _env = UpnpIntegrationEnv::new().await.unwrap(); + let peer_mgr = create_test_peer_manager( + "upnp-test-disabled", + Some(TEST_NS_A), + true, + Box::new(MockStunInfoCollector { + udp_nat_type: NatType::PortRestricted, + }), + ) + .await; + let mut event_rx = peer_mgr.get_global_ctx().subscribe(); + + let listener = UdpHolePunchListener::new(peer_mgr.clone()).await.unwrap(); + let local_port = listener.get_socket().await.local_addr().unwrap().port(); + + let event = tokio::time::timeout(Duration::from_secs(2), event_rx.recv()).await; + assert!(event.is_err(), "unexpected port mapping event: {event:?}"); + assert!(!mapping_exists(local_port).await); + + drop(listener); +} + +#[tokio::test] +#[serial_test::serial(upnp)] +async fn udp_hole_punch_succeeds_via_upnp_mappings_with_different_external_ports() { + let _env = DualGatewayUpnpIntegrationEnv::new().await.unwrap(); + + let p_a = create_test_peer_manager( + "upnp-test-a", + Some(DUAL_NS_A), + false, + Box::new(GatewayBackedStunCollector { + netns: DUAL_NS_A, + client_ip: DUAL_CLIENT_A_IP, + external_ip: DUAL_EXTERNAL_A_IP, + }), + ) + .await; + let mut event_rx_a = p_a.get_global_ctx().subscribe(); + let p_b = create_test_peer_manager( + "upnp-test-b", + None, + false, + Box::new(MockStunInfoCollector { + udp_nat_type: NatType::Unknown, + }), + ) + .await; + let p_c = create_test_peer_manager( + "upnp-test-c", + Some(DUAL_NS_C), + false, + Box::new(GatewayBackedStunCollector { + netns: DUAL_NS_C, + client_ip: DUAL_CLIENT_C_IP, + external_ip: DUAL_EXTERNAL_C_IP, + }), + ) + .await; + let mut event_rx_c = p_c.get_global_ctx().subscribe(); + + connect_peer_manager(p_a.clone(), p_b.clone()).await; + connect_peer_manager(p_b.clone(), p_c.clone()).await; + timeout_stage( + "wait_route_appear(a,c)", + Duration::from_secs(10), + wait_route_appear(p_a.clone(), p_c.clone()), + ) + .await + .unwrap(); + timeout_stage( + "wait_route_appear_with_cost(a,c,2)", + Duration::from_secs(10), + wait_route_appear_with_cost(p_a.clone(), p_c.my_peer_id(), Some(2)), + ) + .await + .unwrap(); + let mut hole_punching_a = UdpHolePunchConnector::new(p_a.clone()); + let mut hole_punching_c = UdpHolePunchConnector::new(p_c.clone()); + hole_punching_a.run_as_client().await.unwrap(); + hole_punching_c.run_as_server().await.unwrap(); + + timeout_stage( + "udp_hole_punch_run_immediately(a)", + Duration::from_secs(10), + hole_punching_a.run_immediately_for_test(), + ) + .await; + + let event_a = timeout_stage( + "wait_port_mapping_event(a)", + Duration::from_secs(15), + wait_for_port_mapping_event(&mut event_rx_a), + ) + .await; + let event_c = timeout_stage( + "wait_port_mapping_event(c)", + Duration::from_secs(15), + wait_for_port_mapping_event(&mut event_rx_c), + ) + .await; + + let (local_port_a, mapped_port_a) = match event_a { + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + assert_eq!(backend, "igd"); + ( + local_listener.port().unwrap(), + mapped_listener.port().unwrap(), + ) + } + other => panic!("unexpected event for a: {other:?}"), + }; + let (local_port_c, mapped_port_c) = match event_c { + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + assert_eq!(backend, "igd"); + ( + local_listener.port().unwrap(), + mapped_listener.port().unwrap(), + ) + } + other => panic!("unexpected event for c: {other:?}"), + }; + + assert_ne!(mapped_port_a, local_port_a); + assert_ne!(mapped_port_c, local_port_c); + + let mapped_addr_a = timeout_stage( + "query_udp_mapping(a)", + Duration::from_secs(10), + query_udp_mapping( + DUAL_NS_A, + DUAL_EXTERNAL_A_IP, + DUAL_CLIENT_A_IP, + local_port_a, + ), + ) + .await + .unwrap(); + let mapped_addr_c = timeout_stage( + "query_udp_mapping(c)", + Duration::from_secs(10), + query_udp_mapping( + DUAL_NS_C, + DUAL_EXTERNAL_C_IP, + DUAL_CLIENT_C_IP, + local_port_c, + ), + ) + .await + .unwrap(); + + assert_eq!(mapped_addr_a.port(), mapped_port_a); + assert_eq!(mapped_addr_c.port(), mapped_port_c); + + timeout_stage( + "wait_route_cost_1_after_udp_hole_punch", + Duration::from_secs(15), + wait_for_condition( + || { + let p_a = p_a.clone(); + let p_c = p_c.clone(); + async move { + let a_ok = p_a + .list_routes() + .await + .iter() + .any(|route| route.peer_id == p_c.my_peer_id() && route.cost == 1); + let c_ok = p_c + .list_routes() + .await + .iter() + .any(|route| route.peer_id == p_a.my_peer_id() && route.cost == 1); + a_ok && c_ok + } + }, + Duration::from_secs(15), + ), + ) + .await; + + assert_ne!(mapped_addr_a.port(), local_port_a); + assert_ne!(mapped_addr_c.port(), local_port_c); +} + +#[tokio::test] +#[serial_test::serial(upnp)] +async fn instances_build_direct_connection_via_upnp_udp_hole_punch() { + let _env = DualGatewayUpnpIntegrationEnv::new().await.unwrap(); + + let mut inst_a = create_test_instance( + "upnp-inst-a", + Some(DUAL_NS_A), + "10.144.200.1/24", + "fd20::1/64", + Box::new(GatewayBackedStunCollector { + netns: DUAL_NS_A, + client_ip: DUAL_CLIENT_A_IP, + external_ip: DUAL_EXTERNAL_A_IP, + }), + |flags| flags.need_p2p = true, + ); + let mut event_rx_a = inst_a.get_global_ctx().subscribe(); + + let mut inst_b = create_test_instance( + "upnp-inst-b", + None, + "10.144.200.2/24", + "fd20::2/64", + Box::new(MockStunInfoCollector { + udp_nat_type: NatType::Unknown, + }), + |_| {}, + ); + + let mut inst_c = create_test_instance( + "upnp-inst-c", + Some(DUAL_NS_C), + "10.144.200.3/24", + "fd20::3/64", + Box::new(GatewayBackedStunCollector { + netns: DUAL_NS_C, + client_ip: DUAL_CLIENT_C_IP, + external_ip: DUAL_EXTERNAL_C_IP, + }), + |flags| flags.need_p2p = true, + ); + let mut event_rx_c = inst_c.get_global_ctx().subscribe(); + + inst_a.run().await.unwrap(); + inst_b.run().await.unwrap(); + inst_c.run().await.unwrap(); + + inst_a + .get_conn_manager() + .add_connector(RingTunnelConnector::new( + format!("ring://{}", inst_b.id()).parse().unwrap(), + )); + inst_c + .get_conn_manager() + .add_connector(RingTunnelConnector::new( + format!("ring://{}", inst_b.id()).parse().unwrap(), + )); + + timeout_stage( + "wait_route_appear(inst_a, inst_c)", + Duration::from_secs(10), + wait_route_appear(inst_a.get_peer_manager(), inst_c.get_peer_manager()), + ) + .await + .unwrap(); + timeout_stage( + "wait_route_cost_2(inst_a -> inst_c)", + Duration::from_secs(10), + wait_route_appear_with_cost(inst_a.get_peer_manager(), inst_c.peer_id(), Some(2)), + ) + .await + .unwrap(); + + timeout_stage( + "ping_over_relay_before_p2p", + Duration::from_secs(10), + wait_for_condition( + || async { ping_test(DUAL_NS_A, "10.144.200.3", None).await }, + Duration::from_secs(10), + ), + ) + .await; + + let event_a = timeout_stage( + "wait_instance_port_mapping_event(a)", + Duration::from_secs(15), + wait_for_port_mapping_event(&mut event_rx_a), + ) + .await; + let event_c = timeout_stage( + "wait_instance_port_mapping_event(c)", + Duration::from_secs(15), + wait_for_port_mapping_event(&mut event_rx_c), + ) + .await; + + let (local_port_a, mapped_port_a) = match event_a { + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + assert_eq!(backend, "igd"); + ( + local_listener.port().unwrap(), + mapped_listener.port().unwrap(), + ) + } + other => panic!("unexpected instance event for a: {other:?}"), + }; + let (local_port_c, mapped_port_c) = match event_c { + GlobalCtxEvent::ListenerPortMappingEstablished { + local_listener, + mapped_listener, + backend, + } => { + assert_eq!(backend, "igd"); + ( + local_listener.port().unwrap(), + mapped_listener.port().unwrap(), + ) + } + other => panic!("unexpected instance event for c: {other:?}"), + }; + + assert_ne!(mapped_port_a, local_port_a); + assert_ne!(mapped_port_c, local_port_c); + + let mapped_addr_a = timeout_stage( + "query_instance_udp_mapping(a)", + Duration::from_secs(10), + query_udp_mapping( + DUAL_NS_A, + DUAL_EXTERNAL_A_IP, + DUAL_CLIENT_A_IP, + local_port_a, + ), + ) + .await + .unwrap(); + let mapped_addr_c = timeout_stage( + "query_instance_udp_mapping(c)", + Duration::from_secs(10), + query_udp_mapping( + DUAL_NS_C, + DUAL_EXTERNAL_C_IP, + DUAL_CLIENT_C_IP, + local_port_c, + ), + ) + .await + .unwrap(); + + assert_eq!(mapped_addr_a.port(), mapped_port_a); + assert_eq!(mapped_addr_c.port(), mapped_port_c); + + timeout_stage( + "wait_instance_direct_peer_via_upnp_and_route_cost_1", + Duration::from_secs(20), + wait_for_condition( + || { + let peer_mgr_a = inst_a.get_peer_manager(); + let peer_mgr_c = inst_c.get_peer_manager(); + let peer_id_a = inst_a.peer_id(); + let peer_id_c = inst_c.peer_id(); + async move { + peer_mgr_a.get_peer_map().has_peer(peer_id_c) + && peer_mgr_c.get_peer_map().has_peer(peer_id_a) + && peer_mgr_a + .list_routes() + .await + .iter() + .any(|route| route.peer_id == peer_id_c && route.cost == 1) + && peer_mgr_c + .list_routes() + .await + .iter() + .any(|route| route.peer_id == peer_id_a && route.cost == 1) + && peer_has_udp_conn_to_remote_addr( + peer_mgr_a.clone(), + peer_id_c, + mapped_addr_c, + ) + .await + && peer_has_udp_conn_to_remote_addr( + peer_mgr_c.clone(), + peer_id_a, + mapped_addr_a, + ) + .await + } + }, + Duration::from_secs(20), + ), + ) + .await; + + timeout_stage( + "ping_over_direct_p2p_after_upnp", + Duration::from_secs(10), + wait_for_condition( + || async { ping_test(DUAL_NS_A, "10.144.200.3", None).await }, + Duration::from_secs(10), + ), + ) + .await; + + drop_insts(vec![inst_a, inst_b, inst_c]).await; +} diff --git a/easytier/src/tunnel/packet_def.rs b/easytier/src/tunnel/packet_def.rs index 0103c4ff..7d78bbf5 100644 --- a/easytier/src/tunnel/packet_def.rs +++ b/easytier/src/tunnel/packet_def.rs @@ -28,10 +28,19 @@ pub enum UdpPacketType { Data = 3, Fin = 4, HolePunch = 5, - V6HolePunch = 6, // when receiving v6 hole punch packet, the packet contains a socket addr of other peer, we + V4HolePunch = 6, // when receiving v4 hole punch packet, the packet contains a socket addr of other peer, we + // will send a hole punch packet to that peer. we only accept this packet from loopback interface. + V6HolePunch = 7, // when receiving v6 hole punch packet, the packet contains a socket addr of other peer, we // will send a hole punch packet to that peer. we only accept this packet from lookback interface. } +#[repr(C, packed)] +#[derive(AsBytes, FromBytes, FromZeroes, Clone, Debug, Default)] +pub struct V4HolePunchPacket { + pub dst_ipv4: [u8; 4], + pub dst_port: U16, +} + #[repr(C, packed)] #[derive(AsBytes, FromBytes, FromZeroes, Clone, Debug, Default)] pub struct V6HolePunchPacket { diff --git a/easytier/src/tunnel/udp.rs b/easytier/src/tunnel/udp.rs index dde69c7e..7bd2ecf9 100644 --- a/easytier/src/tunnel/udp.rs +++ b/easytier/src/tunnel/udp.rs @@ -1,6 +1,6 @@ use std::{ fmt::Debug, - net::{Ipv6Addr, SocketAddrV6}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, sync::{Arc, Weak}, }; @@ -12,7 +12,6 @@ use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; use rand::{Rng, SeedableRng}; use zerocopy::{AsBytes, FromBytes}; -use std::net::SocketAddr; use tokio::{ net::UdpSocket, sync::mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, @@ -24,7 +23,7 @@ use super::{ FromUrl, IpVersion, Tunnel, TunnelConnCounter, TunnelError, TunnelInfo, TunnelListener, TunnelUrl, common::wait_for_connect_futures, - packet_def::{UDP_TUNNEL_HEADER_SIZE, UDPTunnelHeader, V6HolePunchPacket}, + packet_def::{UDP_TUNNEL_HEADER_SIZE, UDPTunnelHeader, V4HolePunchPacket, V6HolePunchPacket}, ring::{RingSink, RingStream}, }; use crate::tunnel::common::bind; @@ -114,6 +113,28 @@ pub fn new_v6_hole_punch_packet(dst: &SocketAddrV6) -> ZCPacket { ) } +pub fn new_v4_hole_punch_packet(dst: &SocketAddrV4) -> ZCPacket { + let mut body = V4HolePunchPacket::default(); + body.dst_ipv4.copy_from_slice(&dst.ip().octets()); + body.dst_port.set(dst.port()); + new_udp_packet( + |header| { + header.msg_type = UdpPacketType::V4HolePunch as u8; + header.conn_id.set(dst.port() as u32); + header + .len + .set(std::mem::size_of::() as u16); + }, + Some(body.as_bytes()), + ) +} + +fn extract_dst_addr_from_v4_hole_punch_packet(buf: &[u8]) -> Option { + let body = V4HolePunchPacket::ref_from_prefix(buf)?; + let ip = Ipv4Addr::from(body.dst_ipv4); + Some(SocketAddrV4::new(ip, body.dst_port.get())) +} + fn extrace_dst_addr_from_hole_punch_packet(buf: &[u8]) -> Option { let body = V6HolePunchPacket::ref_from_prefix(buf)?; let ip = Ipv6Addr::from(body.dst_ipv6); @@ -142,6 +163,21 @@ pub async fn send_v6_hole_punch_packet( Ok(()) } +pub async fn send_v4_hole_punch_packet( + listener_port: u16, + dst_addr: SocketAddrV4, +) -> Result<(), TunnelError> { + let local_socket = UdpSocket::bind("127.0.0.1:0").await?; + let udp_packet = new_v4_hole_punch_packet(&dst_addr); + let remote_addr = format!("127.0.0.1:{}", listener_port) + .parse::() + .unwrap(); + local_socket + .send_to(&udp_packet.into_bytes(), remote_addr) + .await?; + Ok(()) +} + async fn respond_stun_packet( socket: Arc, addr: SocketAddr, @@ -455,6 +491,27 @@ impl UdpTunnelListenerData { tracing::error!(?e, "udp respond stun packet error"); } }); + } else if header.msg_type == UdpPacketType::V4HolePunch as u8 { + if !addr.ip().is_loopback() { + tracing::warn!(?addr, "v4 hole punch packet should be from loopback"); + return; + } + if !addr.ip().is_ipv4() { + tracing::warn!(?addr, "v4 hole punch packet should be sent from ipv4"); + return; + } + let Some(dst_addr) = + extract_dst_addr_from_v4_hole_punch_packet(zc_packet.udp_payload()) + else { + tracing::warn!("invalid v4 hole punch packet"); + return; + }; + let socket = self.socket.as_ref().unwrap().clone(); + let udp_packet = new_hole_punch_packet(1, 32); + if let Err(e) = socket.try_send_to(&udp_packet.into_bytes(), SocketAddr::V4(dst_addr)) { + tracing::error!(?e, "udp send hole punch packet error"); + } + tracing::debug!(?dst_addr, "udp forward packet send hole punch packet"); } else if header.msg_type == UdpPacketType::V6HolePunch as u8 { if !addr.ip().is_loopback() { tracing::warn!(?addr, "v6 hole punch packet should be from loopback"); @@ -527,6 +584,12 @@ impl UdpTunnelListener { } } + pub fn new_with_socket(addr: url::Url, socket: Arc) -> Self { + let mut listener = Self::new(addr); + listener.socket = Some(socket); + listener + } + pub fn get_socket(&self) -> Option> { self.socket.clone() } @@ -535,15 +598,17 @@ impl UdpTunnelListener { #[async_trait] impl TunnelListener for UdpTunnelListener { async fn listen(&mut self) -> Result<(), TunnelError> { - let addr = SocketAddr::from_url(self.addr.clone(), IpVersion::Both).await?; - let tunnel_url: TunnelUrl = self.addr.clone().into(); - self.socket = Some(Arc::new( - bind() - .addr(addr) - .only_v6(true) - .maybe_dev(tunnel_url.bind_dev()) - .call()?, - )); + if self.socket.is_none() { + let addr = SocketAddr::from_url(self.addr.clone(), IpVersion::Both).await?; + let tunnel_url: TunnelUrl = self.addr.clone().into(); + self.socket = Some(Arc::new( + bind() + .addr(addr) + .only_v6(true) + .maybe_dev(tunnel_url.bind_dev()) + .call()?, + )); + } self.data.socket = self.socket.clone(); self.addr @@ -1147,4 +1212,35 @@ mod tests { .expect("Timeout waiting for v6 hole punch packet") .unwrap(); } + + #[tokio::test] + async fn test_v4_hole_punch_packet() { + let mut lis = UdpTunnelListener::new("udp://0.0.0.0:0".parse().unwrap()); + lis.listen().await.unwrap(); + + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let socket_clone = socket.clone(); + let t = tokio::spawn(async move { + let mut buf = BytesMut::new(); + buf.resize(128, 0); + socket_clone.recv_from(&mut buf).await.unwrap(); + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + send_v4_hole_punch_packet( + lis.local_url().port().unwrap(), + match socket.local_addr().unwrap() { + std::net::SocketAddr::V4(addr_v4) => addr_v4, + _ => panic!("Expected an IPv4 address"), + }, + ) + .await + .unwrap(); + + tokio::time::timeout(tokio::time::Duration::from_secs(2), t) + .await + .expect("Timeout waiting for v4 hole punch packet") + .unwrap(); + } }