Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub edc0107fc8 Initial plan 2026-05-25 09:41:42 +00:00
204 changed files with 3478 additions and 33832 deletions
+6 -13
View File
@@ -33,19 +33,6 @@ runs:
sudo apt-get install -qqy build-essential mold musl-tools
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
version: '35.1'
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
- name: Verify protoc version
run: |
version="$(protoc --version | tr -d '\r')"
test "$version" = "libprotoc 35.1"
shell: bash
- name: Setup Frontend Environment
if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm
@@ -95,3 +82,9 @@ runs:
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
+2 -2
View File
@@ -41,8 +41,8 @@ runs:
pnpm -r install
if [ -n "${{ inputs.build-filter }}" ]; then
echo "Building with filter: ${{ inputs.build-filter }}"
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
pnpm -r --filter "${{ inputs.build-filter }}" build
else
echo "No build filter provided, building all packages"
pnpm -r --workspace-concurrency=1 build
pnpm -r build
fi
+1 -1
View File
@@ -35,7 +35,6 @@ jobs:
with:
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -244,3 +243,4 @@ jobs:
ohpm publish easytier-release.har
fi
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
-4
View File
@@ -34,7 +34,6 @@ easytier-panic.log
# web
node_modules
easytier-web/frontend-lib/src/generated/
.vite
@@ -44,6 +43,3 @@ easytier-gui/src-tauri/*.sys
.direnv
.flake-profile
# contrib
go.sum
Generated
+88 -482
View File
@@ -129,12 +129,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.15"
@@ -247,28 +241,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "ariadne"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f"
dependencies = [
"unicode-width 0.1.11",
"yansi",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -937,7 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1151,12 +1129,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.10"
@@ -1266,39 +1238,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "cidr"
version = "0.3.1"
@@ -1468,8 +1407,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8030735ecb0d128428b64cd379809817e620a40e5001c54465b99ec5feec2857"
dependencies = [
"futures-core",
"prost 0.13.5",
"prost-types 0.13.5",
"prost",
"prost-types",
"tonic",
"tracing-core",
]
@@ -1487,8 +1426,8 @@ dependencies = [
"hdrhistogram",
"humantime",
"hyper-util",
"prost 0.13.5",
"prost-types 0.13.5",
"prost",
"prost-types",
"serde",
"serde_json",
"thread_local",
@@ -1644,42 +1583,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -2175,16 +2078,6 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@@ -2208,17 +2101,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users 0.4.5",
"winapi",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -2352,7 +2234,6 @@ dependencies = [
"aes-gcm",
"anyhow",
"arc-swap",
"ariadne",
"async-recursion",
"async-ringbuf",
"async-stream",
@@ -2374,7 +2255,6 @@ dependencies = [
"clap_complete",
"clap_complete_nushell",
"console-subscriber",
"criterion",
"crossbeam",
"ctor 0.8.0",
"dashmap",
@@ -2392,13 +2272,12 @@ dependencies = [
"gethostname 0.5.0",
"git-version",
"globwalk",
"guarden 0.2.0",
"guarden",
"hickory-client",
"hickory-proto",
"hickory-resolver",
"hickory-server",
"hmac",
"hotpath",
"http",
"http_req",
"humansize",
@@ -2425,22 +2304,21 @@ dependencies = [
"ordered_hash_map",
"parking_lot",
"paste",
"pbjson",
"pbjson-build",
"percent-encoding",
"petgraph",
"petgraph 0.8.1",
"pin-project-lite",
"pnet",
"prefix-trie",
"proc-macro2",
"prost 0.14.3",
"prost",
"prost-build",
"prost-reflect",
"prost-reflect-build",
"prost-wkt",
"prost-wkt-build",
"prost-wkt-types",
"quanta",
"quinn",
"quinn-proto",
"quinn-plaintext",
"quote",
"rand 0.8.5",
"rcgen",
@@ -2452,7 +2330,6 @@ dependencies = [
"rstest",
"rust-i18n",
"rustls",
"seahash",
"serde",
"serde_json",
"serial_test",
@@ -2508,7 +2385,6 @@ version = "0.1.0"
dependencies = [
"android_logger",
"easytier",
"easytier-ffi",
"jni",
"log",
"once_cell",
@@ -2520,17 +2396,11 @@ dependencies = [
name = "easytier-ffi"
version = "0.1.0"
dependencies = [
"async-trait",
"dashmap",
"easytier",
"log",
"once_cell",
"percent-encoding",
"serde",
"serde_json",
"tokio",
"tokio-util",
"url",
"uuid",
]
@@ -2580,7 +2450,7 @@ dependencies = [
"dashmap",
"easytier",
"futures",
"guarden 0.1.2",
"guarden",
"jsonwebtoken",
"mimalloc",
"mockall",
@@ -2741,12 +2611,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding"
version = "0.2.33"
@@ -3041,6 +2905,12 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "fixedbitset"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -3722,18 +3592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca87812d87fa82896df1adfb5c111cdeaae3edb6da028f5df002dcbd7df71454"
dependencies = [
"futures",
"guarden-macros 0.1.2",
"tokio",
]
[[package]]
name = "guarden"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8408903291a7d0cc74169d5de4dd1919a9a402a2f67fcd7df3303ed045fae73"
dependencies = [
"futures-core",
"guarden-macros 0.2.0",
"guarden-macros",
"tokio",
]
@@ -3748,18 +3607,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "guarden-macros"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0ef28f1077c259f9e7e238e234a78ce18cedbf0251fd2135f5fc23c40e79fe"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.7"
@@ -4005,61 +3852,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "hotpath"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ff6b552a6afa29d9e33f8d555bee9093c142dd449501ae128e6494a303f03dc"
dependencies = [
"arc-swap",
"cfg-if",
"crossbeam-channel",
"flate2",
"flume 0.12.0",
"futures-util",
"hdrhistogram",
"hotpath-macros",
"hotpath-meta",
"libc",
"object",
"parking_lot",
"pin-project-lite",
"prettytable-rs",
"quanta",
"regex",
"rustc-demangle",
"serde",
"serde_json",
"tiny_http",
"tokio",
]
[[package]]
name = "hotpath-macros"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f15322569d3cfadf84c0de7ef72be435b8f4b4839ee4ace78a7eaca48a87ded"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "hotpath-macros-meta"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3675e29d16c844ccad12763672b33e51d9a000c346720c4f354f7a3bdc649a8"
[[package]]
name = "hotpath-meta"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d558d972ddc9483fb4e713af3dd41634edf895c7cb18ed13dc58c56431d42e27"
dependencies = [
"hotpath-macros-meta",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -4245,7 +4037,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -4589,9 +4381,9 @@ dependencies = [
[[package]]
name = "inventory"
version = "0.3.24"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227"
dependencies = [
"rustversion",
]
@@ -4667,17 +4459,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
@@ -5796,7 +5577,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6028,15 +5809,6 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -6047,12 +5819,6 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -6388,28 +6154,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbjson"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "pbjson-build"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"prost 0.14.3",
"prost-types 0.14.3",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -6445,13 +6189,23 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "petgraph"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset 0.4.2",
"indexmap 2.14.0",
]
[[package]]
name = "petgraph"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a98c6720655620a521dcc722d0ad66cd8afd5d86e34a89ef691c50b7b24de06"
dependencies = [
"fixedbitset",
"fixedbitset 0.5.7",
"hashbrown 0.15.3",
"indexmap 2.14.0",
"serde",
@@ -6683,34 +6437,6 @@ dependencies = [
"time",
]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "pnet"
version = "0.35.0"
@@ -6941,19 +6667,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prettytable-rs"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
dependencies = [
"encode_unicode",
"is-terminal",
"lazy_static",
"term",
"unicode-width 0.1.11",
]
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -6984,11 +6697,11 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.22.20",
]
[[package]]
@@ -7072,33 +6785,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive 0.14.3",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.3"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
"petgraph",
"once_cell",
"petgraph 0.6.5",
"prettyplease",
"prost 0.14.3",
"prost-types 0.14.3",
"prost",
"prost-types",
"regex",
"syn 2.0.117",
"tempfile",
@@ -7117,38 +6821,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "prost-reflect"
version = "0.16.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
checksum = "e92b959d24e05a3e2da1d0beb55b48bc8a97059b8336ea617780bd6addbbfb5a"
dependencies = [
"base64 0.22.1",
"prost 0.14.3",
"once_cell",
"prost",
"prost-reflect-derive",
"prost-types 0.14.3",
"serde",
"serde-value",
"prost-types",
]
[[package]]
name = "prost-reflect-build"
version = "0.16.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8214ae2c30bbac390db0134d08300e770ef89b6d4e5abf855e8d300eded87e28"
checksum = "50e2537231d94dd2778920c2ada37dd9eb1ac0325bb3ee3ee651bd44c1134123"
dependencies = [
"prost-build",
"prost-reflect",
@@ -7156,9 +6845,9 @@ dependencies = [
[[package]]
name = "prost-reflect-derive"
version = "0.16.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b6d90e29fa6c0d13c2c19ba5e4b3fb0efbf5975d27bcf4e260b7b15455bcabe"
checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65"
dependencies = [
"proc-macro2",
"quote",
@@ -7171,27 +6860,18 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost 0.14.3",
"prost",
]
[[package]]
name = "prost-wkt"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3de5e9c9e84fcb5efa204b8e283d23e615a8bc8c777bf1d6622bb01dc61445"
checksum = "497e1e938f0c09ef9cabe1d49437b4016e03e8f82fbbe5d1c62a9b61b9decae1"
dependencies = [
"chrono",
"inventory",
"prost 0.14.3",
"prost",
"serde",
"serde_derive",
"serde_json",
@@ -7200,27 +6880,27 @@ dependencies = [
[[package]]
name = "prost-wkt-build"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe500dc80e757a75e1e8fb7290e448d62dfba3105ece1d058579cb00b58151cd"
checksum = "07b8bf115b70a7aa5af1fd5d6e9418492e9ccb6e4785e858c938e28d132a884b"
dependencies = [
"heck 0.5.0",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"quote",
]
[[package]]
name = "prost-wkt-types"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13807eaa7e15833d06e899008371926201cdcd11d74b6d490f49130cdb3f415e"
checksum = "c8cdde6df0a98311c839392ca2f2f0bcecd545f86a62b4e3c6a49c336e970fe5"
dependencies = [
"chrono",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"prost-wkt",
"prost-wkt-build",
"regex",
@@ -7255,21 +6935,6 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi 0.11.0+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -7314,6 +6979,18 @@ dependencies = [
"web-time",
]
[[package]]
name = "quinn-plaintext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3e617feaeb6493018fa35fc47ae8b630ac8903d8159e9e747018841b99bad3d"
dependencies = [
"bytes",
"quinn-proto",
"seahash",
"tracing",
]
[[package]]
name = "quinn-proto"
version = "0.11.12"
@@ -7523,15 +7200,6 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.8.0",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -7897,7 +7565,7 @@ checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate 3.5.0",
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"regex",
@@ -8013,12 +7681,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.0"
@@ -9943,17 +9605,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "term"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
dependencies = [
"dirs-next",
"rustversion",
"winapi",
]
[[package]]
name = "terminal_size"
version = "0.4.1"
@@ -10109,18 +9760,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "825f6c8a18bc36d56a62f66af7296385b628c9c5543a8663d4c217fc920bfefd"
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.7.6"
@@ -10131,16 +9770,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.8.0"
@@ -10260,7 +9889,8 @@ dependencies = [
[[package]]
name = "tokio-websockets"
version = "0.13.2"
source = "git+https://github.com/EasyTier/tokio-websockets#dc9771c7c215882349c3cb328877550a3593df21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -10335,15 +9965,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
@@ -10381,18 +10002,6 @@ dependencies = [
"winnow 0.6.18",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.1",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
@@ -10428,7 +10037,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"prost 0.13.5",
"prost",
"socket2 0.5.10",
"tokio",
"tokio-stream",
@@ -10712,7 +10321,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
"petgraph 0.8.1",
]
[[package]]
@@ -10772,9 +10381,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "typetag"
version = "0.2.22"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c"
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
dependencies = [
"erased-serde",
"inventory",
@@ -10785,9 +10394,9 @@ dependencies = [
[[package]]
name = "typetag-impl"
version = "0.2.22"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5"
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
dependencies = [
"proc-macro2",
"quote",
@@ -12240,9 +11849,6 @@ name = "winnow"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
@@ -12624,7 +12230,7 @@ version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -12861,7 +12467,7 @@ version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -13,5 +13,4 @@ log = "0.4"
android_logger = "0.13"
serde = { version = "1.0.220", features = ["derive"] }
serde_json = "1.0"
easytier = { path = "../../easytier" }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = ["ffi-dataplane"] }
easytier = { path = "../../easytier" }
@@ -8,7 +8,6 @@
- 📱 原生 Android JNI 支持
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
- 🛡️ 类型安全的 Java 接口
- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口
- 📝 详细的错误处理和日志记录
## 支持的架构
@@ -177,20 +176,6 @@ public class EasyTierManager {
}
```
### 通用 JSON RPC
`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的
EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持
`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。
```java
String response = EasyTierJNI.callJsonRpc(
"api.logger.LoggerRpcService",
"get_logger_config",
"{}"
);
```
### VPN 服务集成
如果您要在 Android VPN 服务中使用:
@@ -279,4 +264,4 @@ public class EasyTierVpnService extends VpnService {
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
- [Android NDK 文档](https://developer.android.com/ndk)
- [Rust JNI 文档](https://docs.rs/jni/)
- [Rust JNI 文档](https://docs.rs/jni/)
@@ -1,17 +0,0 @@
use std::{env, path::PathBuf};
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if !matches!(target_os.as_str(), "android" | "linux") {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let exports = manifest_dir.join("exports.map");
println!("cargo:rerun-if-changed={}", exports.display());
println!(
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={}",
exports.display()
);
println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL");
}
@@ -1,7 +0,0 @@
{
global:
Java_com_easytier_jni_EasyTierJNI_*;
Java_com_easytier_jni_EasyTierDataPlaneJNI_*;
local:
*;
};
@@ -1,451 +0,0 @@
package com.easytier.jni
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
/**
* EasyTier data-plane API for Android.
*
* Dataplane APIs do not create or start an EasyTier instance by themselves.
* Start an instance with [EasyTierJNI.runNetworkInstance] first, then pass the
* same `instanceName` to [EasyTierDataPlane.tcpConnect],
* [EasyTierDataPlane.tcpBind], or [EasyTierDataPlane.udpBind]. If that instance
* is not running, the native start call fails and the coroutine wrapper throws
* the last EasyTier FFI error.
*
* Typical setup:
* ```
* val instanceName = "android-dataplane-demo"
* val config = """
* instance_name = "$instanceName"
* ipv4 = "10.144.0.1"
* listeners = ["tcp://0.0.0.0:11010"]
*
* [network_identity]
* network_name = "android-dataplane-demo"
* network_secret = "replace-with-a-real-secret"
*
* [[peer]]
* uri = "tcp://peer.example.com:11010"
*
* [flags]
* no_tun = true
* bind_device = false
* """.trimIndent()
*
* EasyTierJNI.runNetworkInstance(config)
* ```
*
* After the instance is running, most callers should use [EasyTierDataPlane]
* and the socket/stream classes below. [EasyTierDataPlaneJNI] is the low-level
* native op-handle ABI used by the coroutine wrappers.
*
* TCP client usage:
* ```
* val stream = EasyTierDataPlane.tcpConnect(instanceName, "10.144.0.2", 8080, 5_000)
* try {
* stream.write("ping".toByteArray(), 5_000)
* val reply = stream.read(4096, 5_000)
* } finally {
* stream.close()
* }
* ```
*
* TCP server usage:
* ```
* val listener = EasyTierDataPlane.tcpBind(instanceName, 8080, 5_000)
* try {
* val stream = listener.accept(30_000)
* try {
* stream.write(stream.read(4096, 5_000), 5_000)
* } finally {
* stream.close()
* }
* } finally {
* listener.close()
* }
* ```
*
* UDP usage:
* ```
* val socket = EasyTierDataPlane.udpBind(instanceName, 0, 5_000)
* try {
* socket.sendTo("10.144.0.2", 9000, "ping".toByteArray(), 5_000)
* val packet = socket.recvFrom(4096, 5_000)
* } finally {
* socket.close()
* }
* ```
*
* Operation model:
* - Each suspend function starts one native async op, waits on Dispatchers.IO,
* then consumes the op with the matching finish call.
* - Coroutine cancellation cancels and frees the native op.
* - Returned stream/listener/socket handles must be closed by the caller.
* - Input ByteArray data is copied by the native start call; output data is
* copied into Kotlin ByteArray before the native buffer is freed.
*/
/** Data-plane IPv4/port pair returned by EasyTier FFI. */
data class DataPlaneSocketAddress(val ip: String, val port: Int)
/** Result of a completed TCP connect op. */
data class DataPlaneTcpConnectResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP bind op. */
data class DataPlaneTcpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP accept op. */
data class DataPlaneTcpAcceptResult(
val handle: Long,
val localAddress: DataPlaneSocketAddress,
val peerAddress: DataPlaneSocketAddress
)
/** Result of a completed TCP read op. */
data class DataPlaneTcpReadResult(val data: ByteArray)
/** Result of a completed UDP bind op. */
data class DataPlaneUdpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed UDP recv_from op. */
data class DataPlaneUdpRecvResult(
val data: ByteArray,
val peerAddress: DataPlaneSocketAddress
)
/** TCP data-plane stream handle. Call [close] when the stream is no longer needed. */
class DataPlaneTcpStream(
val handle: Long,
val localAddress: DataPlaneSocketAddress? = null,
val peerAddress: DataPlaneSocketAddress? = null
) {
/** Read up to [maxLength] bytes, waiting at most [timeoutMs] in native code. */
suspend fun read(maxLength: Int, timeoutMs: Long): ByteArray =
EasyTierDataPlane.tcpRead(this, maxLength, timeoutMs)
/** Write [data], waiting at most [timeoutMs] in native code. */
suspend fun write(data: ByteArray, timeoutMs: Long): Int =
EasyTierDataPlane.tcpWrite(this, data, timeoutMs)
/** Close the native TCP stream handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpClose(handle)
}
/** TCP data-plane listener handle. Call [close] when the listener is no longer needed. */
class DataPlaneTcpListener(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Accept one TCP data-plane stream. */
suspend fun accept(timeoutMs: Long): DataPlaneTcpStream =
EasyTierDataPlane.tcpAccept(this, timeoutMs)
/** Close the native TCP listener handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpListenerClose(handle)
}
/** UDP data-plane socket handle. Call [close] when the socket is no longer needed. */
class DataPlaneUdpSocket(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Send one UDP datagram to [dstIp]:[dstPort]. */
suspend fun sendTo(
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int = EasyTierDataPlane.udpSendTo(this, dstIp, dstPort, data, timeoutMs)
/** Receive one UDP datagram and its peer address. */
suspend fun recvFrom(maxLength: Int, timeoutMs: Long): DataPlaneUdpRecvResult =
EasyTierDataPlane.udpRecvFrom(this, maxLength, timeoutMs)
/** Close the native UDP socket handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneUdpClose(handle)
}
/**
* Low-level native data-plane JNI entry points.
*
* These functions mirror the Rust FFI op-handle ABI directly. They are exposed
* for completeness, but most Android callers should use [EasyTierDataPlane]
* instead so coroutine cancellation and op cleanup are handled consistently.
*/
object EasyTierDataPlaneJNI {
init {
System.loadLibrary("easytier_android_jni")
}
@JvmStatic external fun dataPlaneAsyncOpStatus(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpWait(handle: Long, timeoutMs: Long): Int
@JvmStatic external fun dataPlaneAsyncOpCancel(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpFree(handle: Long): Int
@JvmStatic
external fun dataPlaneTcpConnectStart(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpConnectFinish(op: Long): DataPlaneTcpConnectResult?
@JvmStatic
external fun dataPlaneTcpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpBindFinish(op: Long): DataPlaneTcpBindResult?
@JvmStatic external fun dataPlaneTcpAcceptStart(handle: Long, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpAcceptFinish(op: Long): DataPlaneTcpAcceptResult?
@JvmStatic external fun dataPlaneTcpReadStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpReadFinish(op: Long): DataPlaneTcpReadResult?
@JvmStatic external fun dataPlaneTcpWriteStart(handle: Long, data: ByteArray, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpWriteFinish(op: Long): Int
@JvmStatic
external fun dataPlaneUdpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpBindFinish(op: Long): DataPlaneUdpBindResult?
@JvmStatic
external fun dataPlaneUdpSendToStart(
handle: Long,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpSendToFinish(op: Long): Int
@JvmStatic external fun dataPlaneUdpRecvFromStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneUdpRecvFromFinish(op: Long): DataPlaneUdpRecvResult?
@JvmStatic external fun dataPlaneTcpClose(handle: Long): Int
@JvmStatic external fun dataPlaneTcpListenerClose(handle: Long): Int
@JvmStatic external fun dataPlaneUdpClose(handle: Long): Int
}
/** Coroutine-friendly Android data-plane API. */
object EasyTierDataPlane {
private const val DATA_PLANE_OP_PENDING = 0
private const val DATA_PLANE_OP_READY = 1
private const val DATA_PLANE_OP_FAILED = -1
private const val DATA_PLANE_OP_INVALID = -2
private const val DATA_PLANE_WAIT_SLICE_MS = 50L
/** Connect to a TCP endpoint through the named EasyTier instance. */
@JvmStatic
suspend fun tcpConnect(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpConnectStart(
instanceName,
dstIp,
dstPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpConnectFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress)
}
/** Bind a TCP data-plane listener on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun tcpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneTcpListener {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpListener(result.handle, result.localAddress)
}
/** Accept one TCP stream from [listener]. */
@JvmStatic
suspend fun tcpAccept(listener: DataPlaneTcpListener, timeoutMs: Long): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpAcceptStart(listener.handle, timeoutMs)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpAcceptFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress, result.peerAddress)
}
/** Read up to [maxLength] bytes from [stream]. */
@JvmStatic
suspend fun tcpRead(
stream: DataPlaneTcpStream,
maxLength: Int,
timeoutMs: Long
): ByteArray {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpReadStart(
stream.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpReadFinish(it)?.data
?: throw lastDataPlaneException()
}
}
/** Write [data] to [stream]. */
@JvmStatic
suspend fun tcpWrite(stream: DataPlaneTcpStream, data: ByteArray, timeoutMs: Long): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpWriteStart(
stream.handle,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneTcpWriteFinish(it) }
}
/** Bind a UDP data-plane socket on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun udpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneUdpSocket {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneUdpSocket(result.handle, result.localAddress)
}
/** Send one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpSendTo(
socket: DataPlaneUdpSocket,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpSendToStart(
socket.handle,
dstIp,
dstPort,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneUdpSendToFinish(it) }
}
/** Receive one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpRecvFrom(
socket: DataPlaneUdpSocket,
maxLength: Int,
timeoutMs: Long
): DataPlaneUdpRecvResult {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromStart(
socket.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromFinish(it) ?: throw lastDataPlaneException()
}
}
private fun requireOp(op: Long): Long {
if (op == 0L) {
throw lastDataPlaneException()
}
return op
}
private suspend fun <T> awaitOp(op: Long, finish: (Long) -> T): T =
withContext(Dispatchers.IO) {
var consumed = false
try {
awaitReady(op)
val result = finish(op)
consumed = true
result
} catch (e: CancellationException) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpCancel(op)
throw e
} finally {
if (!consumed) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpFree(op)
}
}
}
private suspend fun awaitReady(op: Long) {
while (true) {
currentCoroutineContext().ensureActive()
when (EasyTierDataPlaneJNI.dataPlaneAsyncOpWait(op, DATA_PLANE_WAIT_SLICE_MS)) {
DATA_PLANE_OP_READY, DATA_PLANE_OP_FAILED -> return
DATA_PLANE_OP_PENDING -> Unit
DATA_PLANE_OP_INVALID -> throw RuntimeException("Data-plane async operation is invalid")
else -> throw RuntimeException("Unknown data-plane async operation status")
}
}
}
private fun lastDataPlaneException(): RuntimeException {
return RuntimeException(EasyTierJNI.getLastError() ?: "EasyTier data-plane call failed")
}
}
@@ -1,11 +1,8 @@
package com.easytier.jni
fun interface ConfigServerEventCallback {
fun onEvent(eventJson: String)
}
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
object EasyTierJNI {
init {
// 加载本地库
System.loadLibrary("easytier_android_jni")
@@ -36,35 +33,6 @@ object EasyTierJNI {
*/
@JvmStatic external fun runNetworkInstance(config: String): Int
/**
* 启动配置服务器客户端
* @param url 配置服务器 URL
* @param hostname 主机名,传入 null 使用系统主机名
* @param machineId 稳定机器 ID,由调用方负责持久化
* @param secureMode 是否启用 secure mode
* @param callback 远程配置应用/删除事件回调
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端启动失败时抛出异常
*/
@JvmStatic
external fun startConfigServerClient(
url: String,
hostname: String?,
machineId: String,
secureMode: Boolean,
callback: ConfigServerEventCallback?
): Int
/**
* 停止配置服务器客户端
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端停止失败时抛出异常
*/
@JvmStatic external fun stopConfigServerClient(): Int
/** 查询配置服务器客户端是否已连接 */
@JvmStatic external fun isConfigServerClientConnected(): Boolean
/**
* 保留指定的网络实例,停止其他实例
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
@@ -76,48 +44,11 @@ object EasyTierJNI {
/**
* 收集网络信息
* @param maxLength 最大返回条目数
* @return 包含网络信息的 JSON 字符串
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
/**
* 列出当前运行的实例名称和实例 ID。
* @param maxLength 最大返回条目数
* @return JSON 对象,key 为 instance namevalue 为 instance id
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun listInstances(maxLength: Int): String?
/**
* 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。
*
* 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。
* payloadJson 需要包含目标 RPC 所需的 instance selector。
*
* @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService
* @param methodName RPC 方法名,支持 snake_case 或 proto 方法名
* @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp
* @param payloadJson protobuf JSON 请求体
* @return protobuf JSON 响应体
* @throws RuntimeException 当 RPC 调用失败时抛出异常
*/
@JvmStatic
external fun callJsonRpc(
serviceName: String,
methodName: String,
domainName: String?,
payloadJson: String
): String?
/**
* 调用不需要 domainName 的 EasyTier RPC 方法。
*/
@JvmStatic
fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? {
return callJsonRpc(serviceName, methodName, null, payloadJson)
}
/**
* 获取最后的错误消息
* @return 错误消息字符串,如果没有错误则返回 null
@@ -1,124 +0,0 @@
use std::{
ffi::{CStr, c_char, c_void},
sync::{Arc, Mutex, MutexGuard},
};
use easytier_ffi::ConfigServerEventCallback;
use jni::JNIEnv;
use jni::objects::{GlobalRef, JObject, JValue};
use once_cell::sync::Lazy;
use crate::error;
pub(crate) struct JniConfigServerCallback {
java_vm: jni::JavaVM,
callback: GlobalRef,
}
static CONFIG_SERVER_CALLBACK: Lazy<Mutex<Option<Arc<JniConfigServerCallback>>>> =
Lazy::new(|| Mutex::new(None));
pub(crate) fn lock_callback_storage()
-> Result<MutexGuard<'static, Option<Arc<JniConfigServerCallback>>>, String> {
CONFIG_SERVER_CALLBACK
.lock()
.map_err(|e| format!("Failed to lock config server callback: {}", e))
}
pub(crate) fn new_callback(
env: &mut JNIEnv,
callback: &JObject,
) -> Result<Arc<JniConfigServerCallback>, String> {
let java_vm = env
.get_java_vm()
.map_err(|e| format!("Failed to get JavaVM: {:?}", e))?;
let callback = env
.new_global_ref(callback)
.map_err(|e| format!("Failed to create callback global ref: {:?}", e))?;
Ok(Arc::new(JniConfigServerCallback { java_vm, callback }))
}
pub(crate) fn callback_fn(
callback: &Option<Arc<JniConfigServerCallback>>,
) -> ConfigServerEventCallback {
callback
.as_ref()
.map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void))
}
pub(crate) fn user_data(callback: &Option<Arc<JniConfigServerCallback>>) -> *mut c_void {
callback
.as_ref()
.map(|callback| Arc::as_ptr(callback) as *mut c_void)
.unwrap_or(std::ptr::null_mut())
}
impl JniConfigServerCallback {
fn clear_pending_exception(
env: &mut JNIEnv,
context: &str,
error: &dyn std::fmt::Debug,
) -> String {
match env.exception_check() {
Ok(true) => {
if let Err(clear_err) = env.exception_clear() {
return format!(
"{}: {:?}; failed to clear pending Java exception: {:?}",
context, error, clear_err
);
}
}
Ok(false) => {}
Err(check_err) => {
return format!(
"{}: {:?}; failed to check pending Java exception: {:?}",
context, error, check_err
);
}
}
format!("{}: {:?}", context, error)
}
fn on_event(&self, event_json: *const c_char) -> Result<(), String> {
let event_json = unsafe { CStr::from_ptr(event_json) }
.to_str()
.map_err(|e| format!("Invalid config server event JSON: {:?}", e))?;
let mut env = self
.java_vm
.attach_current_thread()
.map_err(|e| format!("Failed to attach callback thread: {:?}", e))?;
let event_json = env.new_string(event_json).map_err(|e| {
Self::clear_pending_exception(&mut env, "Failed to create event string", &e)
})?;
if let Err(e) = env.call_method(
self.callback.as_obj(),
"onEvent",
"(Ljava/lang/String;)V",
&[JValue::from(&event_json)],
) {
return Err(Self::clear_pending_exception(
&mut env,
"Failed to call config server callback",
&e,
));
}
Ok(())
}
}
unsafe extern "C" fn config_server_event_callback(
event_json: *const c_char,
user_data: *mut c_void,
) {
if event_json.is_null() || user_data.is_null() {
return;
}
let callback = unsafe { &*(user_data as *const JniConfigServerCallback) };
if let Err(error) = callback.on_event(event_json) {
error::set_callback_error(error);
}
}
@@ -1,140 +0,0 @@
use std::ptr;
use easytier_ffi::{
in_config_server_callback, is_config_server_client_connected, start_config_server_client,
stop_config_server_client,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint};
use crate::{
callback, error,
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn start_config_server_client_jni(
env: &mut JNIEnv,
config_server_url: JString,
hostname: JString,
machine_id: JString,
secure_mode: jboolean,
callback_obj: JObject,
) -> jint {
if in_config_server_callback() {
error::throw_exception(
env,
"Cannot start config server client from config server callback",
);
return -1;
}
let config_server_url = match jstring_to_cstring(env, &config_server_url) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid config server URL: {}", e));
return -1;
}
};
let hostname = match optional_jstring_to_cstring(env, &hostname) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid hostname: {}", e));
return -1;
}
};
let machine_id = match jstring_to_cstring(env, &machine_id) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid machine ID: {}", e));
return -1;
}
};
let callback_ref = if callback_obj.is_null() {
None
} else {
match callback::new_callback(env, &callback_obj) {
Ok(state) => Some(state),
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
}
};
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
};
if callback_guard.is_none() {
error::clear_callback_error();
}
let callback_fn = callback::callback_fn(&callback_ref);
let user_data = callback::user_data(&callback_ref);
let result = unsafe {
start_config_server_client(
config_server_url.as_ptr(),
hostname
.as_ref()
.map(|value| value.as_ptr())
.unwrap_or(ptr::null()),
machine_id.as_ptr(),
secure_mode == JNI_TRUE,
callback_fn,
user_data,
)
};
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(env, &error_msg);
}
return result;
}
*callback_guard = callback_ref;
result
}
pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint {
if in_config_server_callback() {
let result = stop_config_server_client();
if result != 0
&& let Some(error_msg) = error::get_last_error()
{
error::throw_exception(&mut env, &error_msg);
}
return result;
}
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(&mut env, &e);
return -1;
}
};
let result = stop_config_server_client();
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(&mut env, &error_msg);
}
return result;
}
*callback_guard = None;
result
}
pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean {
if is_config_server_client_connected() != 0 {
JNI_TRUE
} else {
JNI_FALSE
}
}
@@ -1,673 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{
data_plane_async_op_cancel, data_plane_async_op_free, data_plane_async_op_status,
data_plane_async_op_wait, data_plane_free_bytes, data_plane_tcp_accept_finish,
data_plane_tcp_accept_start, data_plane_tcp_bind_finish, data_plane_tcp_bind_start,
data_plane_tcp_close, data_plane_tcp_connect_finish, data_plane_tcp_connect_start,
data_plane_tcp_listener_close, data_plane_tcp_read_finish, data_plane_tcp_read_start,
data_plane_tcp_write_finish, data_plane_tcp_write_start, data_plane_udp_bind_finish,
data_plane_udp_bind_start, data_plane_udp_close, data_plane_udp_recv_from_finish,
data_plane_udp_recv_from_start, data_plane_udp_send_to_finish, data_plane_udp_send_to_start,
free_string,
};
use jni::{
JNIEnv,
objects::{JByteArray, JClass, JObject, JString, JValue},
sys::{jint, jlong, jobject},
};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
const SOCKET_ADDR_CLASS: &str = "com/easytier/jni/DataPlaneSocketAddress";
const TCP_CONNECT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpConnectResult";
const TCP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpBindResult";
const TCP_ACCEPT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpAcceptResult";
const TCP_READ_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpReadResult";
const UDP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpBindResult";
const UDP_RECV_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpRecvResult";
fn timeout_from_jlong(timeout_ms: jlong) -> u64 {
timeout_ms.max(0) as u64
}
fn port_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u16> {
match u16::try_from(value) {
Ok(port) => Some(port),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn len_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u32> {
match u32::try_from(value) {
Ok(len) => Some(len),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn throw_last(env: &mut JNIEnv) {
let message = get_last_error().unwrap_or_else(|| "EasyTier data-plane call failed".to_string());
throw_exception(env, &message);
}
unsafe fn take_ffi_string(ptr: *const c_char) -> String {
if ptr.is_null() {
return String::new();
}
let value = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
free_string(ptr);
value
}
fn new_socket_addr<'local>(
env: &mut JNIEnv<'local>,
ip: String,
port: u16,
) -> Option<JObject<'local>> {
let class = match env.find_class(SOCKET_ADDR_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
env,
&format!("Failed to find socket address class: {:?}", err),
);
return None;
}
};
let ip = match env.new_string(ip) {
Ok(ip) => ip,
Err(err) => {
throw_exception(env, &format!("Failed to create IP string: {:?}", err));
return None;
}
};
match env.new_object(
class,
"(Ljava/lang/String;I)V",
&[JValue::Object(&ip), JValue::Int(port as jint)],
) {
Ok(addr) => Some(addr),
Err(err) => {
throw_exception(env, &format!("Failed to create socket address: {:?}", err));
None
}
}
}
fn new_handle_addr_result(
env: &mut JNIEnv,
class_name: &str,
handle: u64,
ip: String,
port: u16,
) -> jobject {
let Some(addr) = new_socket_addr(env, ip, port) else {
return ptr::null_mut();
};
let class = match env.find_class(class_name) {
Ok(class) => class,
Err(err) => {
throw_exception(env, &format!("Failed to find result class: {:?}", err));
return ptr::null_mut();
}
};
let sig = format!("(JL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Long(handle as jlong), JValue::Object(&addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(env, &format!("Failed to create result object: {:?}", err));
ptr::null_mut()
}
}
}
fn close_tcp_stream_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_close(handle);
}
result
}
fn close_tcp_listener_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_listener_close(handle);
}
result
}
fn close_udp_socket_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_udp_close(handle);
}
result
}
fn read_owned_bytes(ptr: *const u8, len: u32) -> Vec<u8> {
if ptr.is_null() || len == 0 {
return Vec::new();
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec();
data_plane_free_bytes(ptr, len);
bytes
}
pub(crate) fn async_op_status_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_status(handle as u64)
}
pub(crate) fn async_op_wait_jni(
_env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jint {
data_plane_async_op_wait(handle as u64, timeout_ms.max(0) as u64)
}
pub(crate) fn async_op_cancel_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_cancel(handle as u64)
}
pub(crate) fn async_op_free_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_free(handle as u64)
}
pub(crate) fn tcp_connect_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
dst_ip: JString,
dst_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_connect_start(
inst_name.as_ptr(),
dst_ip.as_ptr(),
dst_port,
timeout_ms.max(0) as u64,
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_connect_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_connect_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_stream_on_null(
new_handle_addr_result(
&mut env,
TCP_CONNECT_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_listener_on_null(
new_handle_addr_result(
&mut env,
TCP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_accept_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jlong {
let op = unsafe { data_plane_tcp_accept_start(handle as u64, timeout_from_jlong(timeout_ms)) };
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_accept_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut local_ip: *const c_char = ptr::null();
let mut local_port = 0u16;
let mut peer_ip: *const c_char = ptr::null();
let mut peer_port = 0u16;
let handle = unsafe {
data_plane_tcp_accept_finish(
op as u64,
&mut local_ip,
&mut local_port,
&mut peer_ip,
&mut peer_port,
)
};
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let Some(local_addr) =
new_socket_addr(&mut env, unsafe { take_ffi_string(local_ip) }, local_port)
else {
free_string(peer_ip);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(peer_ip) }, peer_port)
else {
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let class = match env.find_class(TCP_ACCEPT_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find accept result class: {:?}", err),
);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
}
};
let sig = format!("(JL{};L{};)V", SOCKET_ADDR_CLASS, SOCKET_ADDR_CLASS);
let result = match env.new_object(
class,
sig.as_str(),
&[
JValue::Long(handle as jlong),
JValue::Object(&local_addr),
JValue::Object(&peer_addr),
],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create accept result: {:?}", err),
);
ptr::null_mut()
}
};
close_tcp_stream_on_null(result, handle)
}
pub(crate) fn tcp_read_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_tcp_read_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_read_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let ret = unsafe { data_plane_tcp_read_finish(op as u64, &mut ptr, &mut len) };
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let class = match env.find_class(TCP_READ_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find read result class: {:?}", err),
);
return ptr::null_mut();
}
};
match env.new_object(class, "([B)V", &[JValue::Object(&array)]) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create read result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_write_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid write buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_tcp_write_start(
handle as u64,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_write_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_tcp_write_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_udp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_udp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_udp_socket_on_null(
new_handle_addr_result(
&mut env,
UDP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn udp_send_to_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
dst_ip: JString,
dst_port: jint,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid UDP send buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_udp_send_to_start(
handle as u64,
dst_ip.as_ptr(),
dst_port,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_send_to_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_udp_send_to_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_recv_from_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_udp_recv_from_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_recv_from_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let ret = unsafe {
data_plane_udp_recv_from_finish(op as u64, &mut ptr, &mut len, &mut ip, &mut port)
};
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
free_string(ip);
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(ip) }, port) else {
return ptr::null_mut();
};
let class = match env.find_class(UDP_RECV_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find UDP recv result class: {:?}", err),
);
return ptr::null_mut();
}
};
let sig = format!("([BL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Object(&array), JValue::Object(&peer_addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create UDP recv result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn tcp_listener_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_listener_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_udp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
@@ -1,74 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
sync::Mutex,
};
use easytier_ffi::{free_string, get_error_msg};
use jni::JNIEnv;
use jni::objects::JClass;
use jni::sys::jstring;
use once_cell::sync::Lazy;
static JNI_CALLBACK_ERROR: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
pub(crate) fn set_callback_error(error: String) {
log::error!("{}", error);
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
pub(crate) fn clear_callback_error() {
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = None;
}
}
fn take_callback_error() -> Option<String> {
JNI_CALLBACK_ERROR
.lock()
.ok()
.and_then(|mut guard| guard.take())
}
fn get_ffi_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_cstr = CStr::from_ptr(error_ptr);
let error_str = error_cstr.to_string_lossy().into_owned();
free_string(error_ptr);
Some(error_str)
}
}
}
pub(crate) fn get_last_error() -> Option<String> {
match (get_ffi_last_error(), take_callback_error()) {
(Some(ffi_error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
ffi_error, callback_error
)),
(Some(ffi_error), None) => Some(ffi_error),
(None, Some(callback_error)) => Some(callback_error),
(None, None) => None,
}
}
pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) {
let _ = env.throw_new("java/lang/RuntimeException", message);
}
pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring {
match get_last_error() {
Some(error) => match env.new_string(&error) {
Ok(jstr) => jstr.into_raw(),
Err(_) => ptr::null_mut(),
},
None => ptr::null_mut(),
}
}
@@ -1,91 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{call_json_rpc, free_string};
use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use crate::{
error::{get_last_error, throw_exception},
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn call_json_rpc_jni(
mut env: JNIEnv,
_class: JClass,
service_name: JString,
method_name: JString,
domain_name: JString,
payload_json: JString,
) -> jstring {
let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid service name: {}", e));
return ptr::null_mut();
}
};
let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid method name: {}", e));
return ptr::null_mut();
}
};
let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid domain name: {}", e));
return ptr::null_mut();
}
};
let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid payload JSON: {}", e));
return ptr::null_mut();
}
};
let domain_name_ptr = domain_name_cstr
.as_ref()
.map_or(ptr::null(), |cstr| cstr.as_ptr());
let mut response_ptr: *const c_char = ptr::null();
let result = unsafe {
call_json_rpc(
service_name_cstr.as_ptr(),
method_name_cstr.as_ptr(),
domain_name_ptr,
payload_json_cstr.as_ptr(),
&mut response_ptr,
)
};
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
if response_ptr.is_null() {
throw_exception(&mut env, "JSON RPC returned a null response");
return ptr::null_mut();
}
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
match env.new_string(&response) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON RPC response string");
ptr::null_mut()
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
use once_cell::sync::Lazy;
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("EasyTier-JNI"),
);
});
pub(crate) fn init() {
Lazy::force(&LOGGER_INIT);
}
@@ -1,261 +0,0 @@
use std::{ffi::CStr, ptr};
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use easytier_ffi::{
KeyValuePair, collect_network_infos, free_string, list_instance, parse_config,
retain_network_instance, run_network_instance, set_tun_fd,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
pub(crate) fn set_tun_fd_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
fd: jint,
) -> jint {
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
return -1;
}
};
unsafe {
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = parse_config(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = run_network_instance(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn retain_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
instance_names: JObjectArray,
) -> jint {
if instance_names.is_null() {
return retain_all(&mut env);
}
let array_length = match env.get_array_length(&instance_names) {
Ok(len) => len as usize,
Err(e) => {
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
return -1;
}
};
if array_length == 0 {
return retain_all(&mut env);
}
let mut c_strings = Vec::with_capacity(array_length);
let mut c_string_ptrs = Vec::with_capacity(array_length);
for i in 0..array_length {
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
Ok(obj) => obj,
Err(e) => {
throw_exception(
&mut env,
&format!("Failed to get array element {}: {:?}", i, e),
);
return -1;
}
};
if java_string.is_null() {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: null", i),
);
return -1;
}
let jstring = JString::from(java_string);
let c_string = match jstring_to_cstring(&mut env, &jstring) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: {}", i, e),
);
return -1;
}
};
c_string_ptrs.push(c_string.as_ptr());
c_strings.push(c_string);
}
unsafe {
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
fn retain_all(env: &mut JNIEnv) -> jint {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(env, &error);
}
result
}
}
pub(crate) fn collect_network_infos_jni(
mut env: JNIEnv,
_class: JClass,
max_length: jint,
) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = collect_network_infos(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = NetworkInstanceRunningInfoMap::default();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(&val) {
Ok(v) => v,
Err(_) => {
throw_exception(&mut env, "Failed to parse JSON");
continue;
}
};
ret.map.insert(key, value);
}
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON string");
ptr::null_mut()
}
}
}
}
pub(crate) fn list_instances_jni(mut env: JNIEnv, _class: JClass, max_length: jint) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = list_instance(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = serde_json::Map::new();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
ret.insert(key, serde_json::Value::String(val));
}
let json_str = serde_json::Value::Object(ret).to_string();
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create instance list JSON string");
ptr::null_mut()
}
}
}
}
@@ -1,23 +0,0 @@
use std::ffi::CString;
use jni::JNIEnv;
use jni::objects::JString;
pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
let java_str = env
.get_string(jstr)
.map_err(|e| format!("Failed to get string: {:?}", e))?;
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
}
pub(crate) fn optional_jstring_to_cstring(
env: &mut JNIEnv,
jstr: &JString,
) -> Result<Option<CString>, String> {
if jstr.is_null() {
return Ok(None);
}
jstring_to_cstring(env, jstr).map(Some)
}
+1 -12
View File
@@ -4,25 +4,14 @@ version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["c-abi", "ffi-dataplane"]
c-abi = []
ffi-dataplane = ["easytier/ffi-dataplane"]
crate-type = ["cdylib"]
[dependencies]
easytier = { path = "../../easytier" }
once_cell = "1.18.0"
dashmap = "6.0"
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
async-trait = "0.1"
log = "0.4"
percent-encoding = "2.3"
url = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
uuid = "1.17.0"
tokio-util = "0.7"
@@ -1,429 +0,0 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define DATA_PLANE_OP_PENDING 0
#define DATA_PLANE_OP_READY 1
#define DATA_PLANE_OP_FAILED -1
#define DATA_PLANE_OP_INVALID -2
extern int run_network_instance(const char *cfg_str);
extern void get_error_msg(const char **out);
extern void free_string(const char *s);
extern int data_plane_async_op_status(uint64_t op);
extern int data_plane_async_op_wait(uint64_t op, uint64_t timeout_ms);
extern int data_plane_async_op_cancel(uint64_t op);
extern int data_plane_async_op_free(uint64_t op);
extern void data_plane_free_bytes(const uint8_t *ptr, uint32_t len);
extern uint64_t data_plane_tcp_connect_start(
const char *inst_name,
const char *dst_ip,
uint16_t dst_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_connect_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_accept_start(uint64_t listener, uint64_t timeout_ms);
extern uint64_t data_plane_tcp_accept_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port,
const char **out_peer_ip,
uint16_t *out_peer_port);
extern uint64_t data_plane_tcp_read_start(
uint64_t stream,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_tcp_read_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len);
extern uint64_t data_plane_tcp_write_start(
uint64_t stream,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_tcp_write_finish(uint64_t op);
extern int data_plane_tcp_close(uint64_t stream);
extern int data_plane_tcp_listener_close(uint64_t listener);
extern uint64_t data_plane_udp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_udp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_udp_send_to_start(
uint64_t socket,
const char *dst_ip,
uint16_t dst_port,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_udp_send_to_finish(uint64_t op);
extern uint64_t data_plane_udp_recv_from_start(
uint64_t socket,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_udp_recv_from_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len,
const char **out_ip,
uint16_t *out_port);
extern int data_plane_udp_close(uint64_t socket);
static void print_last_error(const char *prefix) {
const char *err = NULL;
get_error_msg(&err);
if (err) {
fprintf(stderr, "%s: %s\n", prefix, err);
free_string(err);
} else {
fprintf(stderr, "%s\n", prefix);
}
}
static int parse_ip_port(const char *value, char *ip, size_t ip_len, uint16_t *port) {
const char *colon = strrchr(value, ':');
if (!colon || colon == value || !colon[1]) {
fprintf(stderr, "expected IPv4 target in IP:PORT form, got %s\n", value);
return -1;
}
size_t host_len = (size_t)(colon - value);
if (host_len >= ip_len) {
fprintf(stderr, "IP address is too long: %s\n", value);
return -1;
}
char *end = NULL;
long parsed_port = strtol(colon + 1, &end, 10);
if (!end || *end != '\0' || parsed_port < 0 || parsed_port > 65535) {
fprintf(stderr, "invalid port in %s\n", value);
return -1;
}
memcpy(ip, value, host_len);
ip[host_len] = '\0';
*port = (uint16_t)parsed_port;
return 0;
}
static int wait_op(uint64_t op, uint64_t timeout_ms) {
uint64_t waited = 0;
while (waited < timeout_ms) {
int status = data_plane_async_op_wait(op, 50);
if (status != DATA_PLANE_OP_PENDING) {
return status;
}
waited += 50;
}
return data_plane_async_op_status(op);
}
static int wait_or_cancel(uint64_t op, uint64_t timeout_ms, const char *what) {
int status = wait_op(op, timeout_ms);
if (status == DATA_PLANE_OP_READY || status == DATA_PLANE_OP_FAILED) {
return status;
}
if (status == DATA_PLANE_OP_PENDING) {
fprintf(stderr, "%s did not finish within %llu ms\n", what, (unsigned long long)timeout_ms);
data_plane_async_op_cancel(op);
data_plane_async_op_free(op);
return DATA_PLANE_OP_INVALID;
}
fprintf(stderr, "%s returned invalid op status %d\n", what, status);
return status;
}
static int async_tcp_read_once(uint64_t stream, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_read_start(stream, 512, timeout_ms);
if (!op) {
print_last_error("tcp read start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp read") == DATA_PLANE_OP_INVALID) {
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
int ret = data_plane_tcp_read_finish(op, &buf, &len);
if (ret < 0) {
print_last_error("tcp read finish failed");
return -1;
}
printf("tcp read %d bytes: %.*s\n", ret, ret, buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
return 0;
}
static int async_tcp_write_all(uint64_t stream, const char *data, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_write_start(
stream,
(const uint8_t *)data,
(uint32_t)strlen(data),
timeout_ms);
if (!op) {
print_last_error("tcp write start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp write") == DATA_PLANE_OP_INVALID) {
return -1;
}
int ret = data_plane_tcp_write_finish(op);
if (ret < 0) {
print_last_error("tcp write finish failed");
return -1;
}
printf("tcp wrote %d bytes\n", ret);
return 0;
}
static int run_tcp_connect_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_tcp_connect_start(inst, ip, port, 30000);
if (!op) {
print_last_error("tcp connect start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp connect") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t stream = data_plane_tcp_connect_finish(op, &local_ip, &local_port);
if (!stream) {
print_last_error("tcp connect finish failed");
return -1;
}
printf("tcp connected from %s:%u to %s:%u, handle=%llu\n",
local_ip,
local_port,
ip,
port,
(unsigned long long)stream);
free_string(local_ip);
int ret = async_tcp_read_once(stream, 10000);
data_plane_tcp_close(stream);
return ret;
}
static int run_tcp_listen_demo(const char *inst, const char *port_text) {
uint16_t port = (uint16_t)strtoul(port_text, NULL, 10);
uint64_t op = data_plane_tcp_bind_start(inst, port, 30000);
if (!op) {
print_last_error("tcp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t listener = data_plane_tcp_bind_finish(op, &local_ip, &local_port);
if (!listener) {
print_last_error("tcp bind finish failed");
return -1;
}
printf("tcp listening on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)listener);
free_string(local_ip);
op = data_plane_tcp_accept_start(listener, 60000);
if (!op) {
print_last_error("tcp accept start failed");
data_plane_tcp_listener_close(listener);
return -1;
}
if (wait_or_cancel(op, 61000, "tcp accept") == DATA_PLANE_OP_INVALID) {
data_plane_tcp_listener_close(listener);
return -1;
}
const char *peer_ip = NULL;
uint16_t peer_port = 0;
local_ip = NULL;
local_port = 0;
uint64_t stream = data_plane_tcp_accept_finish(
op,
&local_ip,
&local_port,
&peer_ip,
&peer_port);
data_plane_tcp_listener_close(listener);
if (!stream) {
print_last_error("tcp accept finish failed");
return -1;
}
printf("tcp accepted %s:%u -> %s:%u, stream=%llu\n",
peer_ip,
peer_port,
local_ip,
local_port,
(unsigned long long)stream);
free_string(local_ip);
free_string(peer_ip);
int ret = async_tcp_read_once(stream, 10000);
if (ret == 0) {
ret = async_tcp_write_all(stream, "pong", 10000);
}
data_plane_tcp_close(stream);
return ret;
}
static int run_udp_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_udp_bind_start(inst, 0, 30000);
if (!op) {
print_last_error("udp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "udp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t socket = data_plane_udp_bind_finish(op, &local_ip, &local_port);
if (!socket) {
print_last_error("udp bind finish failed");
return -1;
}
printf("udp bound on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)socket);
free_string(local_ip);
const char payload[] = "ping";
op = data_plane_udp_send_to_start(
socket,
ip,
port,
(const uint8_t *)payload,
(uint32_t)strlen(payload),
10000);
if (!op) {
print_last_error("udp send start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 11000, "udp send") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
int sent = data_plane_udp_send_to_finish(op);
if (sent < 0) {
print_last_error("udp send finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp sent %d bytes to %s:%u\n", sent, ip, port);
op = data_plane_udp_recv_from_start(socket, 512, 30000);
if (!op) {
print_last_error("udp recv start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 31000, "udp recv") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
const char *peer_ip = NULL;
uint16_t peer_port = 0;
int ret = data_plane_udp_recv_from_finish(op, &buf, &len, &peer_ip, &peer_port);
if (ret < 0) {
print_last_error("udp recv finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp received %d bytes from %s:%u: %.*s\n",
ret,
peer_ip,
peer_port,
ret,
buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
free_string(peer_ip);
data_plane_udp_close(socket);
return 0;
}
static void print_usage(void) {
printf("Set EASYTIER_FFI_CONFIG and EASYTIER_FFI_INSTANCE to run the async data-plane demo.\n");
printf("Optional demos:\n");
printf(" EASYTIER_FFI_TARGET=10.0.0.2:22 async TCP connect/read\n");
printf(" EASYTIER_FFI_LISTEN_PORT=12345 async TCP bind/accept/read/write\n");
printf(" EASYTIER_FFI_UDP_TARGET=10.0.0.2:9000 async UDP bind/send_to/recv_from\n");
}
int main(void) {
const char *config = getenv("EASYTIER_FFI_CONFIG");
const char *instance = getenv("EASYTIER_FFI_INSTANCE");
if (!config || !instance) {
print_usage();
return 0;
}
if (run_network_instance(config) != 0) {
print_last_error("run_network_instance failed");
return 1;
}
printf("network instance started: %s\n", instance);
int failed = 0;
const char *target = getenv("EASYTIER_FFI_TARGET");
if (target) {
failed |= run_tcp_connect_demo(instance, target) != 0;
}
const char *listen_port = getenv("EASYTIER_FFI_LISTEN_PORT");
if (listen_port) {
failed |= run_tcp_listen_demo(instance, listen_port) != 0;
}
const char *udp_target = getenv("EASYTIER_FFI_UDP_TARGET");
if (udp_target) {
failed |= run_udp_demo(instance, udp_target) != 0;
}
if (!target && !listen_port && !udp_target) {
printf("No dataplane demo env var was set; nothing else to run.\n");
print_usage();
}
return failed ? 1 : 0;
}
@@ -1,100 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h> // for sleep
// FFI struct and function declarations
typedef struct {
const char* key;
const char* value;
} KeyValuePair;
typedef void (*config_server_event_callback)(
const char* event_json,
void* user_data
);
extern int parse_config(const char* cfg_str);
extern int run_network_instance(const char* cfg_str);
extern void get_error_msg(const char** out);
extern void free_string(const char* s);
extern int collect_network_infos(KeyValuePair* infos, size_t max_length);
extern int start_config_server_client(
const char* config_server_url,
const char* hostname,
const char* machine_id,
bool secure_mode,
config_server_event_callback callback,
void* user_data
);
extern int stop_config_server_client(void);
extern int is_config_server_client_connected(void);
static void on_config_server_event(const char* event_json, void* user_data) {
(void)user_data;
printf("config server event: %s\n", event_json);
}
int main() {
const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n";
int ret;
// 调用 parse_config
ret = parse_config(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("parse_config error: %s\n", err);
free_string(err);
}
return 1;
}
printf("parse_config success\n");
// 调用 run_network_instance
ret = run_network_instance(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("run_network_instance error: %s\n", err);
free_string(err);
}
return 1;
}
printf("run_network_instance success\n");
// 周期性调用 collect_network_infos 并打印
const size_t max_infos = 8;
KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos);
if (!infos) {
fprintf(stderr, "malloc failed\n");
return 1;
}
for (int i = 0; i < 5; ++i) { // 循环5次作为示例
memset(infos, 0, sizeof(KeyValuePair) * max_infos);
int count = collect_network_infos(infos, max_infos);
if (count < 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("collect_network_infos error: %s\n", err);
free_string(err);
}
break;
}
printf("collect_network_infos: %d instance(s)\n", count);
for (int j = 0; j < count; ++j) {
printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value);
free_string(infos[j].key);
free_string(infos[j].value);
}
sleep(1);
}
free(infos);
return 0;
}
@@ -1,138 +0,0 @@
# 1. Go FFI Demo
This demo wraps EasyTier FFI data-plane TCP as Go `net.Conn` and `net.Listener`.
It can connect to an SSH server through EasyTier and read its banner, or accept a
TCP connection from another EasyTier peer and run a small ping/pong exchange.
The async op-handle wrapper is in `easytier_async.go`; the original synchronous
wrapper stays in `easytier.go`.
## 1.1. Build the FFI library
Run from the repository root:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
```
The demo loads the debug library by default:
```text
target/debug/libeasytier_ffi.so
```
To use another library path, export `EASYTIER_FFI_LIB=/path/to/libeasytier_ffi.so`.
## 1.2. Configure the EasyTier config
`EASYTIER_FFI_CONFIG` is a string of the EasyTier config in TOML format which is passed to the FFI library. For example:
```sh
export EASYTIER_FFI_CONFIG='instance_name = "default"
ipv4 = "10.0.0.1"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true # disable tun device to avoid permission issues.
bind_device = false # allow loopback peers in local examples.
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
```
You should configure with your own real values.
Set the local instance name and a SSH server target to connect through EasyTier:
```sh
export EASYTIER_FFI_INSTANCE=default
export EASYTIER_FFI_TARGET=10.0.0.2:22
```
To run the TCP listen integration test in the same `go test` process as the SSH
test, use a separate instance name and config:
```sh
export EASYTIER_FFI_LISTEN_CONFIG='instance_name = "listener"
ipv4 = "10.0.0.3"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true
bind_device = false
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
export EASYTIER_FFI_LISTEN_INSTANCE=listener
export EASYTIER_FFI_LISTEN_PORT=12345
```
## 1.3. Run the demo
`goffi` is built without cgo on Linux, so run the tests with `CGO_ENABLED=0`:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -v ./...
```
The synchronous tests use the environment variables above. The async Go tests
are self-contained: they start two local EasyTier instances in the same test
process with `no_tun = true` and `bind_device = false`, then run TCP and UDP
ping/pong over the async data-plane API.
The synchronous wrapper also exposes `CallJSONRPC(service, method, domain,
payload)` for non-lifecycle EasyTier RPCs. For example,
`CallJSONRPC("api.logger.LoggerRpcService", "get_logger_config", "", "{}")`
returns the logger config as protobuf JSON. Instance lifecycle management RPCs
are intentionally filtered; use the dedicated FFI APIs for starting and
stopping instances.
To run only the async tests:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -run 'TestAsync' -v ./...
```
When the SSH integration environment variables are set, expected synchronous
test output includes an SSH banner similar to:
```text
attempt 1: got banner "SSH-2.0-..."
PASS
```
For `TestTCPListenIntegration`, connect from another EasyTier peer to the local
EasyTier IPv4 address and `EASYTIER_FFI_LISTEN_PORT`, send `ping`, and expect
`pong` in response.
The async test output should include local TCP bind/connect log lines and finish
with `PASS` without any extra environment variables.
## 1.4. C async example
The C async example is kept separate from the basic C example:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
cc -Wall -Wextra -pedantic \
../example_data_plane_async.c \
-L ../../../../target/debug -leasytier_ffi \
-Wl,-rpath,../../../../target/debug \
-o /tmp/easytier_data_plane_async
/tmp/easytier_data_plane_async
```
Without environment variables it prints usage and exits successfully. With
`EASYTIER_FFI_CONFIG`, `EASYTIER_FFI_INSTANCE`, and one of
`EASYTIER_FFI_TARGET`, `EASYTIER_FFI_LISTEN_PORT`, or `EASYTIER_FFI_UDP_TARGET`,
it runs the corresponding async data-plane flow.
@@ -1,593 +0,0 @@
package easytierffi
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"unsafe"
"github.com/go-webgpu/goffi/ffi"
"github.com/go-webgpu/goffi/types"
)
const defaultTimeout = 30 * time.Second
type Native struct {
lib unsafe.Pointer
runNetworkInstance symCall
callJSONRPC symCall
getErrorMsg symCall
freeString symCall
tcpConnect symCall
tcpBind symCall
tcpAccept symCall
tcpRead symCall
tcpWrite symCall
tcpClose symCall
tcpListenerClose symCall
}
type Conn struct {
native *Native
handle uint64
local net.Addr
remote net.Addr
closed atomic.Bool
rd atomicDeadline
wd atomicDeadline
}
type Listener struct {
native *Native
handle uint64
addr net.Addr
closed atomic.Bool
}
type symCall struct {
fn unsafe.Pointer
cif types.CallInterface
}
type atomicDeadline struct{ v atomic.Int64 }
type timeoutError string
func Open(path string) (*Native, error) {
lib, err := ffi.LoadLibrary(path)
if err != nil {
return nil, err
}
n := &Native{lib: lib}
if err := n.bind(); err != nil {
ffi.FreeLibrary(lib)
return nil, err
}
return n, nil
}
func (n *Native) Close() error {
if n.lib == nil {
return nil
}
ffi.FreeLibrary(n.lib)
n.lib = nil
return nil
}
func (n *Native) RunNetworkInstance(config string) error {
defer pinErrorThread()()
cfg := cString(config)
cfgPtr := unsafe.Pointer(&cfg[0])
var ret int32
err := n.runNetworkInstance.call(unsafe.Pointer(&ret), unsafe.Pointer(&cfgPtr))
runtime.KeepAlive(cfg)
if err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) CallJSONRPC(serviceName, methodName, domainName, payloadJSON string) (string, error) {
defer pinErrorThread()()
service := cString(serviceName)
method := cString(methodName)
payload := cString(payloadJSON)
servicePtr := unsafe.Pointer(&service[0])
methodPtr := unsafe.Pointer(&method[0])
payloadPtr := unsafe.Pointer(&payload[0])
var domain []byte
var domainPtr unsafe.Pointer
if domainName != "" {
domain = cString(domainName)
domainPtr = unsafe.Pointer(&domain[0])
}
var response unsafe.Pointer
responseArg := unsafe.Pointer(&response)
var ret int32
err := n.callJSONRPC.call(
unsafe.Pointer(&ret),
unsafe.Pointer(&servicePtr),
unsafe.Pointer(&methodPtr),
unsafe.Pointer(&domainPtr),
unsafe.Pointer(&payloadPtr),
unsafe.Pointer(&responseArg),
)
runtime.KeepAlive(service)
runtime.KeepAlive(method)
runtime.KeepAlive(domain)
runtime.KeepAlive(payload)
if err != nil {
return "", err
}
if ret != 0 {
return "", n.lastError()
}
if response == nil {
return "", errors.New("easytier ffi JSON RPC returned nil response")
}
defer func() { _ = n.freeCString(response) }()
return readCString(response), nil
}
func (n *Native) DialContext(ctx context.Context, instance, network, address string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
ip, port, err := parseIPPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpConnectTo(instance, ip.String(), uint16(port), timeout)
if err != nil {
return nil, err
}
return &Conn{native: n, handle: handle, local: local, remote: &net.TCPAddr{IP: ip, Port: port}}, nil
}
func (n *Native) ListenContext(ctx context.Context, instance, network, address string) (net.Listener, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
port, err := parseListenPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpBindTo(instance, uint16(port), timeout)
if err != nil {
return nil, err
}
return &Listener{native: n, handle: handle, addr: local}, nil
}
func (c *Conn) Read(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpReadFrom(c.handle, b, c.rd.timeout(defaultTimeout))
if err != nil {
return 0, opError("read", c.remote, err)
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
func (c *Conn) Write(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpWriteTo(c.handle, b, c.wd.timeout(defaultTimeout))
if err != nil {
return 0, opError("write", c.remote, err)
}
return n, nil
}
func (c *Conn) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return c.native.tcpCloseHandle(c.handle)
}
func (c *Conn) LocalAddr() net.Addr { return c.local }
func (c *Conn) RemoteAddr() net.Addr { return c.remote }
func (c *Conn) SetDeadline(t time.Time) error { c.rd.set(t); c.wd.set(t); return nil }
func (c *Conn) SetReadDeadline(t time.Time) error { c.rd.set(t); return nil }
func (c *Conn) SetWriteDeadline(t time.Time) error { c.wd.set(t); return nil }
func (l *Listener) Accept() (net.Conn, error) {
if l.closed.Load() {
return nil, net.ErrClosed
}
for {
handle, local, peer, err := l.native.tcpAcceptFrom(l.handle, defaultTimeout)
if err == nil {
return &Conn{native: l.native, handle: handle, local: local, remote: peer}, nil
}
if l.closed.Load() {
return nil, net.ErrClosed
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return nil, opError("accept", l.addr, err)
}
}
func (l *Listener) Close() error {
if !l.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return l.native.tcpListenerCloseHandle(l.handle)
}
func (l *Listener) Addr() net.Addr { return l.addr }
func (n *Native) bind() error {
return errors.Join(
n.bindSym(&n.runNetworkInstance, "run_network_instance", types.SInt32TypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.callJSONRPC, "call_json_rpc", types.SInt32TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.getErrorMsg, "get_error_msg", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.freeString, "free_string", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpConnect, "data_plane_tcp_connect", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpBind, "data_plane_tcp_bind", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpAccept, "data_plane_tcp_accept", types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpRead, "data_plane_tcp_read", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpWrite, "data_plane_tcp_write", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpClose, "data_plane_tcp_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpListenerClose, "data_plane_tcp_listener_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
)
}
func (n *Native) bindSym(dst *symCall, name string, ret *types.TypeDescriptor, args ...*types.TypeDescriptor) error {
sym, err := ffi.GetSymbol(n.lib, name)
if err != nil {
return err
}
if err := ffi.PrepareCallInterface(&dst.cif, types.DefaultCall, ret, args); err != nil {
return err
}
dst.fn = sym
return nil
}
func (s *symCall) call(ret unsafe.Pointer, args ...unsafe.Pointer) error {
// `ffi.CallFunction` and libffi `ffi_call` are safe to invoke concurrently
// because `cif` is prepared once during binding and only read afterwards.
return ffi.CallFunction(&s.cif, s.fn, ret, args)
}
func (n *Native) tcpConnectTo(instance, ip string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
dst := cString(ip)
instPtr := unsafe.Pointer(&inst[0])
dstPtr := unsafe.Pointer(&dst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpConnect.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&dstPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
runtime.KeepAlive(dst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpBindTo(instance string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
instPtr := unsafe.Pointer(&inst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpBind.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpAcceptFrom(handle uint64, timeout time.Duration) (uint64, *net.TCPAddr, *net.TCPAddr, error) {
defer pinErrorThread()()
timeoutMS := uint64(timeout / time.Millisecond)
var stream uint64
var outLocalIP unsafe.Pointer
outLocalIPArg := unsafe.Pointer(&outLocalIP)
var outLocalPort uint16
outLocalPortArg := unsafe.Pointer(&outLocalPort)
var outPeerIP unsafe.Pointer
outPeerIPArg := unsafe.Pointer(&outPeerIP)
var outPeerPort uint16
outPeerPortArg := unsafe.Pointer(&outPeerPort)
err := n.tcpAccept.call(
unsafe.Pointer(&stream),
unsafe.Pointer(&handle),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outLocalIPArg),
unsafe.Pointer(&outLocalPortArg),
unsafe.Pointer(&outPeerIPArg),
unsafe.Pointer(&outPeerPortArg),
)
if err != nil {
return 0, nil, nil, err
}
if stream == 0 {
return 0, nil, nil, n.lastError()
}
return stream, n.takeTCPAddr(outLocalIP, outLocalPort), n.takeTCPAddr(outPeerIP, outPeerPort), nil
}
func (n *Native) tcpReadFrom(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpRead.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpWriteTo(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpWrite.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) tcpListenerCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpListenerClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
// pinErrorThread ties an FFI op to the get_error_msg that reads its result: the
// Rust side stores the last error in a thread-local, so the goroutine must not
// migrate to another OS thread between the two calls. Use as `defer pinErrorThread()()`
// at the start of any wrapper that reports failures through lastError.
func pinErrorThread() func() {
runtime.LockOSThread()
return runtime.UnlockOSThread
}
func (n *Native) lastError() error {
var out unsafe.Pointer
outArg := unsafe.Pointer(&out)
if err := n.getErrorMsg.call(nil, unsafe.Pointer(&outArg)); err != nil {
return err
}
if out == nil {
return errors.New("easytier ffi call failed")
}
msg := readCString(out)
_ = n.freeCString(out)
if strings.Contains(msg, "timed out") {
return timeoutError(msg)
}
return errors.New(msg)
}
func (n *Native) freeCString(ptr unsafe.Pointer) error {
if ptr == nil {
return nil
}
return n.freeString.call(nil, unsafe.Pointer(&ptr))
}
func (n *Native) takeTCPAddr(ipPtr unsafe.Pointer, port uint16) *net.TCPAddr {
if ipPtr == nil {
return nil
}
ip := net.ParseIP(readCString(ipPtr))
_ = n.freeCString(ipPtr)
return &net.TCPAddr{IP: ip, Port: int(port)}
}
func (d *atomicDeadline) set(t time.Time) {
if t.IsZero() {
d.v.Store(0)
return
}
d.v.Store(t.UnixNano())
}
func (d *atomicDeadline) timeout(fallback time.Duration) time.Duration {
ns := d.v.Load()
if ns == 0 {
return fallback
}
remaining := time.Until(time.Unix(0, ns))
if remaining <= 0 {
return time.Millisecond
}
return remaining
}
func (e timeoutError) Error() string { return string(e) }
func (e timeoutError) Timeout() bool { return true }
func (e timeoutError) Temporary() bool { return true }
func opError(op string, addr net.Addr, err error) error {
return &net.OpError{Op: op, Net: "easytier", Addr: addr, Err: err}
}
func parseIPPort(address string) (net.IP, int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return nil, 0, err
}
ip := net.ParseIP(host)
if ip == nil {
return nil, 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return nil, 0, err
}
return ip, int(port), nil
}
func parseListenPort(address string) (int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return 0, err
}
if host != "" {
ip := net.ParseIP(host)
if ip == nil {
return 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
if !ip.IsUnspecified() {
return 0, fmt.Errorf("easytier ffi listen address must be unspecified, got %q", host)
}
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return int(port), nil
}
func cString(s string) []byte {
if strings.ContainsRune(s, 0) {
panic("easytier ffi string contains NUL")
}
return append([]byte(s), 0)
}
func readCString(ptr unsafe.Pointer) string {
if ptr == nil {
return ""
}
var b []byte
for p := uintptr(ptr); ; p++ {
c := *(*byte)(unsafe.Pointer(p))
if c == 0 {
return string(b)
}
b = append(b, c)
}
}
func defaultLibraryPath() string {
if p := os.Getenv("EASYTIER_FFI_LIB"); p != "" {
return p
}
switch runtime.GOOS {
case "darwin":
return "../../../../target/debug/libeasytier_ffi.dylib"
case "windows":
return "..\\..\\..\\..\\target\\debug\\easytier_ffi.dll"
default:
return "../../../../target/debug/libeasytier_ffi.so"
}
}
var _ net.Conn = (*Conn)(nil)
var _ net.Listener = (*Listener)(nil)
File diff suppressed because it is too large Load Diff
@@ -1,360 +0,0 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"testing"
"time"
)
const asyncLocalTestTimeout = 120 * time.Second
func TestAsyncSymbolBinding(t *testing.T) {
n := openAsyncForTest(t)
status, err := n.opWaitStatus(0, 0)
if err != nil {
t.Fatal(err)
}
if status != dataPlaneOpInvalid {
t.Fatalf("expected invalid status for op 0, got %d", status)
}
}
func TestAsyncLocalTwoNodeTCPAndUDP(t *testing.T) {
n := openAsyncForTest(t)
topology := startLocalAsyncTopology(t, n)
ctx, cancel := context.WithTimeout(context.Background(), asyncLocalTestTimeout)
defer cancel()
runAsyncTCPPingPong(t, ctx, n, topology)
runAsyncUDPPingPong(t, ctx, n, topology)
}
type localAsyncTopology struct {
dialerInstance string
listenerInstance string
listenerIP string
}
func openAsyncForTest(t *testing.T) *AsyncNative {
t.Helper()
libraryPath := defaultLibraryPath()
if _, err := os.Stat(libraryPath); err != nil {
if os.IsNotExist(err) {
t.Skipf("build easytier-ffi with ffi-dataplane before running async tests: %v", err)
}
t.Fatalf("stat async ffi library: %v", err)
}
n, err := OpenAsync(libraryPath)
if err != nil {
t.Fatalf("open async ffi library: %v", err)
}
t.Cleanup(func() {
if err := n.Close(); err != nil {
t.Errorf("close async native: %v", err)
}
})
return n
}
func startLocalAsyncTopology(t *testing.T, n *AsyncNative) localAsyncTopology {
t.Helper()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
networkName := "ffi-async-" + suffix
networkSecret := "ffi-async-secret-" + suffix
listenerInstance := "ffi-async-listener-" + suffix
dialerInstance := "ffi-async-dialer-" + suffix
listenerIP := "10.251.1.2"
dialerIP := "10.251.1.1"
listenerPort := freeLocalTCPPort(t)
listenerEndpoint := fmt.Sprintf("tcp://127.0.0.1:%d", listenerPort)
t.Cleanup(func() {
if err := n.deleteNetworkInstances([]string{dialerInstance, listenerInstance}); err != nil {
t.Errorf("cleanup async test EasyTier instances: %v", err)
}
})
listenerConfig := localAsyncConfig(
listenerInstance,
listenerIP,
networkName,
networkSecret,
[]string{listenerEndpoint},
nil,
)
dialerConfig := localAsyncConfig(
dialerInstance,
dialerIP,
networkName,
networkSecret,
nil,
[]string{listenerEndpoint},
)
if err := n.RunNetworkInstance(listenerConfig); err != nil {
t.Fatalf("start listener instance: %v", err)
}
if err := n.RunNetworkInstance(dialerConfig); err != nil {
t.Fatalf("start dialer instance: %v", err)
}
return localAsyncTopology{
dialerInstance: dialerInstance,
listenerInstance: listenerInstance,
listenerIP: listenerIP,
}
}
func localAsyncConfig(instance, ipv4, networkName, networkSecret string, listeners, peers []string) string {
config := fmt.Sprintf(`instance_name = %s
ipv4 = %s
listeners = %s
[network_identity]
network_name = %s
network_secret = %s
[flags]
no_tun = true
bind_device = false
`,
strconv.Quote(instance),
strconv.Quote(ipv4),
tomlStringList(listeners),
strconv.Quote(networkName),
strconv.Quote(networkSecret),
)
for _, peer := range peers {
config += fmt.Sprintf("\n[[peer]]\nuri = %s\n", strconv.Quote(peer))
}
return config
}
func tomlStringList(values []string) string {
if len(values) == 0 {
return "[]"
}
out := "["
for i, value := range values {
if i > 0 {
out += ", "
}
out += strconv.Quote(value)
}
return out + "]"
}
func freeLocalTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("allocate local tcp port: %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func runAsyncTCPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
listener, listenerAddr := eventuallyTCPListen(t, ctx, n, topology.listenerInstance)
tcpCtx, cancel := context.WithCancel(ctx)
accepted := make(chan error, 1)
defer waitForAsyncHelper(t, accepted, "tcp accept helper")
defer cancel()
defer listener.Close()
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- fmt.Errorf("accept tcp stream: %w", err)
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
payload := make([]byte, len("ping"))
if _, err := io.ReadFull(conn, payload); err != nil {
accepted <- fmt.Errorf("read tcp ping: %w", err)
return
}
if string(payload) != "ping" {
accepted <- fmt.Errorf("expected tcp ping, got %q", string(payload))
return
}
if _, err := conn.Write([]byte("pong")); err != nil {
accepted <- fmt.Errorf("write tcp pong: %w", err)
return
}
accepted <- nil
}()
conn, err := eventuallyTCPDial(t, tcpCtx, n, topology.dialerInstance, topology.listenerIP, listenerAddr.Port)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatalf("write tcp ping: %v", err)
}
payload := make([]byte, len("pong"))
if _, err := io.ReadFull(conn, payload); err != nil {
t.Fatalf("read tcp pong: %v", err)
}
if string(payload) != "pong" {
t.Fatalf("expected tcp pong, got %q", string(payload))
}
}
func eventuallyTCPListen(t *testing.T, ctx context.Context, n *AsyncNative, instance string) (net.Listener, *net.TCPAddr) {
t.Helper()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
listener, err := n.ListenContext(attemptCtx, instance, "tcp", "0.0.0.0:0")
cancel()
if err == nil {
addr := listener.Addr().(*net.TCPAddr)
t.Logf("async tcp bind succeeded on attempt %d at %s", attempt, addr)
return listener, addr
}
lastErr = err
t.Logf("attempt %d: async tcp bind failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
t.Fatalf("async tcp bind never succeeded: %v", lastErr)
panic("unreachable")
}
func eventuallyTCPDial(t *testing.T, ctx context.Context, n *AsyncNative, instance, ip string, port int) (net.Conn, error) {
t.Helper()
address := net.JoinHostPort(ip, strconv.Itoa(port))
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
conn, err := n.DialContext(attemptCtx, instance, "tcp", address)
cancel()
if err == nil {
t.Logf("async tcp connect succeeded on attempt %d to %s", attempt, address)
return conn, nil
}
lastErr = err
t.Logf("attempt %d: async tcp connect failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
return nil, fmt.Errorf("async tcp connect never succeeded: %w", lastErr)
}
func runAsyncUDPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
dialerSocket, err := n.UDPBindContext(ctx, topology.dialerInstance, 0)
if err != nil {
t.Fatalf("bind dialer udp socket: %v", err)
}
listenerSocket, err := n.UDPBindContext(ctx, topology.listenerInstance, 0)
if err != nil {
t.Fatalf("bind listener udp socket: %v", err)
}
udpCtx, cancel := context.WithCancel(ctx)
warmupDone := make(chan error, 1)
received := make(chan error, 1)
defer waitForAsyncHelper(t, received, "udp receive helper")
defer cancel()
defer listenerSocket.Close()
defer dialerSocket.Close()
go func() {
if _, err := listenerSocket.SendTo(udpCtx, []byte("warmup"), dialerSocket.LocalAddr()); err != nil {
err = fmt.Errorf("send udp warmup: %w", err)
warmupDone <- err
received <- err
return
}
warmupDone <- nil
payload, from, err := listenerSocket.RecvFrom(udpCtx, 512)
if err != nil {
received <- fmt.Errorf("recv udp ping: %w", err)
return
}
if string(payload) != "ping" {
received <- fmt.Errorf("expected udp ping, got %q", string(payload))
return
}
if _, err := listenerSocket.SendTo(udpCtx, []byte("pong"), from); err != nil {
received <- fmt.Errorf("send udp pong: %w", err)
return
}
received <- nil
}()
select {
case err := <-warmupDone:
if err != nil {
t.Fatal(err)
}
case <-udpCtx.Done():
t.Fatal(udpCtx.Err())
}
target := &net.UDPAddr{IP: net.ParseIP(topology.listenerIP), Port: listenerSocket.LocalAddr().Port}
if _, err := dialerSocket.SendTo(udpCtx, []byte("ping"), target); err != nil {
t.Fatalf("send udp ping: %v", err)
}
for {
payload, from, err := dialerSocket.RecvFrom(udpCtx, 512)
if err != nil {
t.Fatalf("recv udp pong: %v", err)
}
if string(payload) == "pong" {
if !from.IP.Equal(target.IP) || from.Port != target.Port {
t.Fatalf("expected udp pong from %s, got %s", target, from)
}
break
}
t.Logf("skipping udp datagram from %s: %q", from, string(payload))
}
}
func waitForAsyncHelper(t *testing.T, done <-chan error, name string) {
t.Helper()
select {
case err := <-done:
if err != nil {
t.Errorf("%s: %v", name, err)
}
case <-time.After(10 * time.Second):
t.Errorf("%s did not stop", name)
}
}
func waitForRetry(ctx context.Context, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
@@ -1,140 +0,0 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestSSHIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_CONFIG")
instance := os.Getenv("EASYTIER_FFI_INSTANCE")
target := os.Getenv("EASYTIER_FFI_TARGET")
if config == "" || instance == "" || target == "" {
t.Skip("set EASYTIER_FFI_CONFIG, EASYTIER_FFI_INSTANCE and EASYTIER_FFI_TARGET to run integration test")
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
conn, err := n.DialContext(ctx, instance, "tcp", target)
if err != nil {
lastErr = err
t.Logf("attempt %d: dial failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
nn, err := conn.Read(buf)
_ = conn.Close()
if err != nil {
lastErr = err
t.Logf("attempt %d: read failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
banner := string(buf[:nn])
if !strings.HasPrefix(banner, "SSH-") {
t.Fatalf("attempt %d: expected SSH banner, got %q", attempt, banner)
}
t.Logf("attempt %d: got banner %q", attempt, strings.TrimRight(banner, "\r\n"))
return
}
t.Fatalf("never got SSH banner, last err: %v", lastErr)
}
func TestTCPListenIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_LISTEN_CONFIG")
instance := os.Getenv("EASYTIER_FFI_LISTEN_INSTANCE")
listenPort := os.Getenv("EASYTIER_FFI_LISTEN_PORT")
if config == "" || instance == "" || listenPort == "" {
t.Skip("set EASYTIER_FFI_LISTEN_CONFIG, EASYTIER_FFI_LISTEN_INSTANCE and EASYTIER_FFI_LISTEN_PORT to run integration test")
}
port, err := strconv.ParseUint(listenPort, 10, 16)
if err != nil {
t.Fatal(err)
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Data-plane readiness is asynchronous: the instance must finish starting
// before the data plane accepts binds. Retry until ready or ctx expires.
var listener net.Listener
for attempt := 1; ; attempt++ {
listener, err = n.ListenContext(ctx, instance, "tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))))
if err == nil {
break
}
if ctx.Err() != nil {
t.Fatalf("bind never succeeded, last err: %v", err)
}
t.Logf("attempt %d: bind failed: %v", attempt, err)
time.Sleep(3 * time.Second)
}
t.Logf("listening on %s; connect from another EasyTier peer and send ping", listener.Addr())
accepted := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
accepted <- err
return
}
if string(buf) != "ping" {
accepted <- fmt.Errorf("expected %q, got %q", "ping", string(buf))
return
}
_, err = conn.Write([]byte("pong"))
accepted <- err
}()
select {
case err := <-accepted:
_ = listener.Close()
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
_ = listener.Close()
t.Fatal(ctx.Err())
}
}
@@ -1,5 +0,0 @@
module easytierffi-example
go 1.25
require github.com/go-webgpu/goffi v0.4.1
@@ -1,2 +0,0 @@
github.com/go-webgpu/goffi v0.4.1 h1:2hQH5XXloxTyTtIleYv+Rajlwzp6UOETURhSZ5+zJxU=
github.com/go-webgpu/goffi v0.4.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM=
@@ -1,575 +0,0 @@
use std::{
cell::Cell,
collections::HashSet,
ffi::{CString, c_char, c_int, c_void},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use easytier::{
common::{
MachineIdOptions,
config::{ConfigLoader as _, TomlConfigLoader},
},
tunnel::TunnelScheme,
web_client::{WebClient, WebClientHooks, run_web_client},
};
use uuid::Uuid;
use crate::{
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
ASYNC_RUNTIME, INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP,
lock_remote_instance_mutation, remove_instance_name_ids,
},
strings::{c_str_to_string, optional_c_str_to_string},
types::ConfigServerEventCallback,
};
thread_local! {
static IN_CONFIG_SERVER_CALLBACK: Cell<bool> = const { Cell::new(false) };
}
static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy<Mutex<Option<ManagedConfigServerClient>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy<Mutex<Option<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
pub(crate) struct ConfigServerCallbackScope;
impl ConfigServerCallbackScope {
pub(crate) fn enter() -> Self {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true));
Self
}
}
impl Drop for ConfigServerCallbackScope {
fn drop(&mut self) {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false));
}
}
pub fn in_config_server_callback() -> bool {
IN_CONFIG_SERVER_CALLBACK.with(Cell::get)
}
fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions {
MachineIdOptions {
explicit_machine_id: Some(machine_id),
state_dir: None,
}
}
pub fn validate_config_server_client_options(
config_server_url_s: &str,
machine_id: &str,
) -> Result<(), String> {
if machine_id.trim().is_empty() {
return Err("machine_id is empty".to_string());
}
let config_server_url = match url::Url::parse(config_server_url_s) {
Ok(url) => url,
Err(_) => format!(
"udp://config-server.easytier.cn:22020/{}",
config_server_url_s
)
.parse()
.map_err(|err| format!("failed to parse config server URL: {}", err))?,
};
TunnelScheme::try_from(&config_server_url).map_err(|_| {
format!(
"unsupported config server scheme: {}",
config_server_url.scheme()
)
})?;
let token = config_server_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.map(|segment| percent_encoding::percent_decode_str(segment).decode_utf8())
.transpose()
.map_err(|err| format!("failed to decode config server token: {}", err))?
.map(|token| token.to_string())
.unwrap_or_default();
if token.is_empty() {
return Err("empty token".to_string());
}
Ok(())
}
struct ManagedConfigServerClient {
client: WebClient,
hooks: Arc<ManagedConfigServerClientHooks>,
}
pub(crate) struct ManagedConfigServerClientHooks {
pub(crate) instance_ids: Mutex<HashSet<Uuid>>,
callback_delivery: Mutex<()>,
stopping: AtomicBool,
callback: ConfigServerEventCallback,
user_data: usize,
}
impl ManagedConfigServerClientHooks {
pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self {
Self {
instance_ids: Mutex::new(HashSet::new()),
callback_delivery: Mutex::new(()),
stopping: AtomicBool::new(false),
callback,
user_data: user_data as usize,
}
}
#[cfg(test)]
pub(crate) fn tracked_instance_ids(&self) -> Vec<Uuid> {
self.instance_ids
.lock()
.map(|guard| guard.iter().copied().collect())
.unwrap_or_default()
}
fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result<Vec<Uuid>, String> {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
Ok(ids
.iter()
.filter_map(|id| guard.remove(id).then_some(*id))
.collect())
}
fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> {
if let Some(existing_id) = INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id)
&& existing_id != inst_id
{
return Err(format!("instance name {} already exists", inst_name));
}
Ok(())
}
fn commit_instance_name(&self, inst_name: String, inst_id: Uuid) -> Result<(), String> {
INSTANCE_NAME_ID_MAP.retain(|_, existing_id| *existing_id != inst_id);
self.validate_instance_name(&inst_name, inst_id)?;
INSTANCE_NAME_ID_MAP.insert(inst_name, inst_id);
Ok(())
}
pub(crate) fn start_stopping(&self) -> Vec<Uuid> {
let _delivery_guard = if in_config_server_callback() {
None
} else {
self.callback_delivery.lock().ok()
};
let mut guard = match self.instance_ids.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
self.stopping.store(true, Ordering::Release);
guard.drain().collect()
}
pub(crate) fn note_callback_error(&self, error: String) {
log::warn!("config server event callback failed: {}", error);
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
fn emit_event_with_delivery_locked(
&self,
event: &str,
instance_id: Uuid,
) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Ok(());
}
let Some(callback) = self.callback else {
return Ok(());
};
let instance_name = INSTANCE_MANAGER
.get_instance_name(&instance_id)
.unwrap_or_default();
let network_name = INSTANCE_MANAGER
.get_network_name(&instance_id)
.unwrap_or_default();
let event_json = serde_json::json!({
"event": event,
"success": true,
"instance_id": instance_id.to_string(),
"instance_name": instance_name,
"network_name": network_name,
"error": null,
})
.to_string();
let event_json = CString::new(event_json).map_err(|err| err.to_string())?;
let _callback_scope = ConfigServerCallbackScope::enter();
unsafe {
callback(event_json.as_ptr(), self.user_data as *mut c_void);
}
Ok(())
}
fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
self.emit_event_with_delivery_locked(event, instance_id)
}
fn wait_for_callback_delivery(&self) {
if in_config_server_callback() {
return;
}
if let Ok(guard) = self.callback_delivery.lock() {
drop(guard);
}
}
}
#[async_trait::async_trait]
impl WebClientHooks for ManagedConfigServerClientHooks {
fn manages_remote_config_instances(&self) -> bool {
true
}
async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Err("config server client is stopping".to_string());
}
let inst_name = cfg.get_inst_name();
let inst_id = cfg.get_id();
self.validate_instance_name(&inst_name, inst_id)
}
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
let Some(inst_name) = INSTANCE_MANAGER.get_instance_name(id) else {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
};
{
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
}
let should_delete = {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
if self.stopping.load(Ordering::Acquire) {
true
} else {
guard.insert(*id);
false
}
};
if should_delete {
if let Err(err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(err.to_string());
}
remove_instance_name_ids(&[*id]);
return Ok(());
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if let Err(err) = self.commit_instance_name(inst_name.clone(), *id) {
self.remove_tracked_instance_ids(&[*id])?;
if let Err(delete_err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(format!(
"{}; failed to delete duplicate instance: {}",
err, delete_err
));
}
return Err(err);
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Err(format!(
"instance {} was removed before post-run completed",
id
));
}
}
remove_data_plane_handles_by_instance_ids(&[*id]);
if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) {
self.note_callback_error(err);
}
Ok(())
}
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
let removed_ids = {
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
let removed_ids = self.remove_tracked_instance_ids(ids)?;
remove_instance_name_ids(ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
removed_ids
};
for id in removed_ids {
if let Err(err) = self.emit_event("delete_network_instance", id) {
self.note_callback_error(err);
}
}
Ok(())
}
}
pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
if let Ok(guard) = CONFIG_SERVER_CLIENT.lock()
&& let Some(managed) = guard.as_ref()
&& let Err(err) = managed.hooks.remove_tracked_instance_ids(ids)
{
log::warn!("failed to remove config server tracked ids: {}", err);
}
}
pub(crate) fn wait_for_config_server_delivery() {
let hooks = CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone()));
if let Some(hooks) = hooks {
hooks.wait_for_callback_delivery();
}
}
pub(crate) fn last_callback_error() -> Option<String> {
LAST_CONFIG_SERVER_CALLBACK_ERROR
.lock()
.ok()
.and_then(|guard| guard.clone())
}
pub(crate) fn clear_last_callback_error() {
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = None;
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn is_config_server_active_or_stopping() -> bool {
CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire)
|| CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn set_active_for_test(active: bool) {
CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release);
}
/// # Safety
/// Start the config server client.
///
/// `config_server_url` must be a valid null-terminated UTF-8 string.
/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string.
/// `machine_id` must be a valid null-terminated UTF-8 string.
/// `event_json` passed to `callback` is valid only during that callback invocation.
pub(crate) unsafe fn start_config_server_client(
config_server_url: *const c_char,
hostname: *const c_char,
machine_id: *const c_char,
secure_mode: bool,
callback: ConfigServerEventCallback,
user_data: *mut c_void,
) -> c_int {
if in_config_server_callback() {
set_error_msg("cannot start config server client from config server callback");
return -1;
}
let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") }
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } {
Err(err) => {
set_error_msg(&err);
return -1;
}
Ok(value) => value,
};
if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) {
set_error_msg(&err);
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
if guard.is_some() {
set_error_msg("config server client already exists");
return -1;
}
if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) {
set_error_msg("config server client is stopping");
return -1;
}
clear_last_callback_error();
#[cfg(feature = "ffi-dataplane")]
let data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release);
#[cfg(feature = "ffi-dataplane")]
drop(data_plane_usage_guard);
let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data));
let client = match ASYNC_RUNTIME.block_on(run_web_client(
&config_server_url,
config_server_machine_id_options(machine_id),
hostname,
secure_mode,
INSTANCE_MANAGER.clone(),
Some(hooks.clone()),
)) {
Ok(client) => client,
Err(err) => {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
set_error_msg(&format!("failed to start config server client: {}", err));
return -1;
}
};
*guard = Some(ManagedConfigServerClient { client, hooks });
0
}
pub(crate) fn stop_config_server_client() -> c_int {
if in_config_server_callback() {
set_error_msg("cannot stop config server client from config server callback");
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
let Some(managed) = guard.as_ref() else {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
return 0;
};
if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) {
set_error_msg("config server client is stopping");
return -1;
}
let hooks = managed.hooks.clone();
let managed = guard.take().expect("config server client exists");
drop(guard);
let _remote_mutation_guard = lock_remote_instance_mutation();
let tracked_ids = hooks.start_stopping();
drop(managed);
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
let delete_result = INSTANCE_MANAGER.delete_network_instance(tracked_ids.clone());
if delete_result.is_ok() {
remove_instance_name_ids(&tracked_ids);
remove_data_plane_handles_by_instance_ids(&tracked_ids);
}
drop(_mutation_guard);
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
if let Err(err) = delete_result {
set_error_msg(&format!(
"failed to delete config server instances: {}",
err
));
return -1;
}
0
}
pub(crate) fn is_config_server_client_connected() -> c_int {
CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected()))
.map(i32::from)
.unwrap_or(0)
}
@@ -1,928 +0,0 @@
#[cfg(feature = "ffi-dataplane")]
use std::{
future::Future,
net::{IpAddr, SocketAddr},
sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
#[cfg(feature = "ffi-dataplane")]
use dashmap::DashMap;
#[cfg(feature = "ffi-dataplane")]
use easytier::launcher::{DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket};
#[cfg(feature = "ffi-dataplane")]
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
#[cfg(feature = "ffi-dataplane")]
use tokio_util::sync::CancellationToken;
#[cfg(feature = "ffi-dataplane")]
use uuid::Uuid;
#[cfg(feature = "ffi-dataplane")]
use crate::{
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
error::{free_string, set_error_msg},
state::{INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP},
};
#[cfg(feature = "ffi-dataplane")]
static NEXT_DATA_PLANE_HANDLE: AtomicU64 = AtomicU64::new(1);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_HANDLES: once_cell::sync::Lazy<DashMap<u64, DataPlaneHandle>> =
once_cell::sync::Lazy::new(DashMap::new);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
once_cell::sync::Lazy::new(|| RwLock::new(()));
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct DataPlaneHandle {
pub(crate) instance_id: uuid::Uuid,
pub(crate) runtime: tokio::runtime::Handle,
// Cancelled by close() to wake any in-flight op on this handle.
pub(crate) close_token: CancellationToken,
pub(crate) resource: DataPlaneResource,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct TcpHalves {
pub(crate) read: tokio::sync::Mutex<ReadHalf<DataPlaneTcpStream>>,
pub(crate) write: tokio::sync::Mutex<WriteHalf<DataPlaneTcpStream>>,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) enum DataPlaneResource {
Tcp(Arc<TcpHalves>),
TcpListener(Arc<tokio::sync::Mutex<DataPlaneTcpListener>>),
Udp(Arc<DataPlaneUdpSocket>),
}
// Several helper functions for FFI data plane operations to facilitate logic reuse.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn next_handle() -> u64 {
NEXT_DATA_PLANE_HANDLE.fetch_add(1, Ordering::Relaxed)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn timeout_duration(timeout_ms: u64) -> Duration {
Duration::from_millis(timeout_ms)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn cstr_to_string(ptr: *const std::ffi::c_char, name: &str) -> Option<String> {
if ptr.is_null() {
set_error_msg(&format!("{} is null", name));
return None;
}
Some(
unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned(),
)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_instance_id(inst_name: &str) -> Option<uuid::Uuid> {
INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id.value())
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn parse_socket_addr(host: &str, port: u16) -> Option<SocketAddr> {
let ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(e) => {
set_error_msg(&format!("failed to parse ip address: {}", e));
return None;
}
};
Some(SocketAddr::new(ip, port))
}
/// Encode an IP address for FFI return. Returns `*mut c_char` to match
/// `CString::into_raw`; caller releases it via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn into_ffi_ip_cstring(ip: IpAddr) -> Option<*mut std::ffi::c_char> {
match std::ffi::CString::new(ip.to_string()) {
Ok(s) => Some(s.into_raw()),
Err(e) => {
set_error_msg(&format!("failed to encode ip: {}", e));
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_runtime_handle(
inst_id: &uuid::Uuid,
deadline: std::time::Instant,
) -> Option<tokio::runtime::Handle> {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let Some(rt) = INSTANCE_MANAGER.data_plane_wait_runtime_handle(inst_id, remaining) else {
set_error_msg("instance runtime is not ready");
return None;
};
Some(rt)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_stream_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
stream: DataPlaneTcpStream,
) -> u64 {
let (rd, wr) = tokio::io::split(stream);
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Tcp(Arc::new(TcpHalves {
read: tokio::sync::Mutex::new(rd),
write: tokio::sync::Mutex::new(wr),
})),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_listener_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
listener: DataPlaneTcpListener,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::TcpListener(Arc::new(tokio::sync::Mutex::new(listener))),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_udp_socket_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
socket: DataPlaneUdpSocket,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Udp(Arc::new(socket)),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream(
handle: u64,
) -> Option<(Arc<TcpHalves>, tokio::runtime::Handle, CancellationToken)> {
get_tcp_stream_with_instance(handle)
.map(|(halves, runtime, close_token, _)| (halves, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream_with_instance(
handle: u64,
) -> Option<(
Arc<TcpHalves>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp stream handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Tcp(halves) => Some((
halves.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::TcpListener(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp stream");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_listener(
handle: u64,
) -> Option<(
Arc<tokio::sync::Mutex<DataPlaneTcpListener>>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp listener handle not found");
return None;
};
match &h.resource {
DataPlaneResource::TcpListener(listener) => Some((
listener.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp listener");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
)> {
get_udp_socket_with_instance(handle)
.map(|(socket, runtime, close_token, _)| (socket, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket_with_instance(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("udp socket handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Udp(socket) => Some((
socket.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::TcpListener(_) => {
set_error_msg("handle is not a udp socket");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn remove_data_plane_handles_by_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
let _data_plane_usage_guard = DATA_PLANE_USAGE_LOCK
.write()
.unwrap_or_else(|err| err.into_inner());
DATA_PLANE_HANDLES.retain(|_, handle| {
if ids.contains(&handle.instance_id) {
handle.close_token.cancel();
false
} else {
true
}
});
crate::data_plane_async::remove_ops_by_instance_ids(ids);
}
#[cfg(not(feature = "ffi-dataplane"))]
pub(crate) fn remove_data_plane_handles_by_instance_ids(_ids: &[uuid::Uuid]) {}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_rejected() -> bool {
if in_config_server_callback() {
set_error_msg("cannot use data plane from config server callback");
true
} else if is_config_server_active_or_stopping() {
set_error_msg("cannot use data plane while config server client is active");
true
} else {
false
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn enter_data_plane_operation() -> Option<std::sync::RwLockReadGuard<'static, ()>> {
if data_plane_rejected() {
return None;
}
let guard = match DATA_PLANE_USAGE_LOCK.read() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock data plane usage: {}", err));
return None;
}
};
if data_plane_rejected() {
return None;
}
Some(guard)
}
/// Run an IO op on the resource's owning runtime, supporting
/// timeout and cancellation.
#[cfg(feature = "ffi-dataplane")]
async fn run_with_cancel<T, F>(
close_token: &CancellationToken,
timeout_ms: u64,
error_prefix: &str,
op: F,
) -> Option<Result<T, std::io::Error>>
where
F: Future<Output = Result<T, std::io::Error>>,
{
tokio::select! {
biased;
_ = close_token.cancelled() => {
set_error_msg(&format!("{}: handle closed", error_prefix));
None
}
res = tokio::time::timeout(timeout_duration(timeout_ms), op) => match res {
Ok(r) => Some(r),
Err(_) => {
set_error_msg(&format!("{} timed out", error_prefix));
None
}
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn lock_for_config_server_start()
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
let guard = DATA_PLANE_USAGE_LOCK
.write()
.map_err(|err| format!("failed to lock data plane usage: {}", err))?;
if !DATA_PLANE_HANDLES.is_empty() || crate::data_plane_async::has_live_ops() {
return Err("cannot start config server client while data plane is in use".to_string());
}
Ok(guard)
}
/// # Safety
/// Open a TCP stream through an EasyTier instance data plane. Returns 0 on
/// failure. On success, writes the local socket address chosen for this
/// connection into `out_local_ip` (a heap-allocated C string the caller must
/// release via `free_string`) and `out_local_port`. Both out pointers must be
/// non-null.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_connect(
inst_name: *const std::ffi::c_char,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_connect(&inst_id, dst_addr, remaining));
match result {
Ok(stream) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_stream_handle(inst_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to connect tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Bind a TCP listener through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_bind(&inst_id, local_port, remaining));
match result {
Ok(listener) => {
let local_addr = listener.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_listener_handle(inst_id, runtime, listener);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Accept one connection from a TCP data-plane listener. Returns a TCP stream
/// handle, or 0 on failure. Local and peer addresses are written into out
/// parameters; returned IP strings must be released via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_accept(
handle: u64,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
out_peer_ip: *mut *const std::ffi::c_char,
out_peer_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null()
|| out_local_port.is_null()
|| out_peer_ip.is_null()
|| out_peer_port.is_null()
{
set_error_msg("output pointer is null");
return 0;
}
let Some((listener, runtime, close_token, instance_id)) = get_tcp_listener(handle) else {
return 0;
};
let ret = runtime.block_on(async move {
let mut listener = listener.lock().await;
run_with_cancel(
&close_token,
timeout_ms,
"tcp data plane accept",
listener.accept(),
)
.await
});
match ret {
Some(Ok((stream, peer_addr))) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let Some(peer_ip) = into_ffi_ip_cstring(peer_addr.ip()) else {
free_string(local_ip);
return 0;
};
let stream_handle = insert_tcp_stream_handle(instance_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
*out_peer_ip = peer_ip as *const std::ffi::c_char;
*out_peer_port = peer_addr.port();
}
stream_handle
}
Some(Err(e)) => {
set_error_msg(&format!("failed to accept tcp data plane: {}", e));
0
}
None => 0,
}
}
/// # Safety
/// Read from a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_read(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) };
runtime.block_on(async move {
let mut rd = halves.read.lock().await;
match run_with_cancel(
&close_token,
timeout_ms,
"failed to read tcp data plane",
rd.read(buf),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to read tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Write to a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_write(
handle: u64,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
let mut wr = halves.write.lock().await;
// Use `write_all` to honor `net.Conn::Write` semantics on the Go side
// (must write everything or return an error); single `write()` can
// silently short-write and corrupt streams that the caller assumes are
// fully written.
match run_with_cancel(
&close_token,
timeout_ms,
"failed to write tcp data plane",
wr.write_all(buf),
)
.await
{
Some(Ok(())) => total as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to write tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Tcp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp stream"
} else {
"tcp stream handle not found"
});
return -1;
};
h.close_token.cancel();
if let DataPlaneResource::Tcp(halves) = h.resource {
// Best-effort half-close; if write half is in use, the in-flight call
// observes the cancel token and releases the lock shortly after.
h.runtime.spawn(async move {
if let Ok(mut wr) = halves.write.try_lock() {
let _ = wr.shutdown().await;
}
});
}
0
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_listener_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::TcpListener(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp listener"
} else {
"tcp listener handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
/// # Safety
/// Bind a UDP socket through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound (which may differ from the
/// requested port when `local_port == 0`) is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_udp_bind(&inst_id, local_port, remaining));
match result {
Ok(socket) => {
let local_addr = socket.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_udp_socket_handle(inst_id, runtime, socket);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind udp data plane: {}", e));
0
}
}
}
/// # Safety
/// Send a datagram through a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_send_to(
handle: u64,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return -1;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return -1;
};
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
match run_with_cancel(
&close_token,
timeout_ms,
"failed to send udp data plane",
socket.send_to(buf, dst_addr),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to send udp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Receive a datagram from a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_recv_from(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
out_ip: *mut *const std::ffi::c_char,
out_port: *mut std::ffi::c_ushort,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() || out_ip.is_null() || out_port.is_null() {
set_error_msg("output pointer is null");
return -1;
}
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, total) };
let ret = runtime.block_on(run_with_cancel(
&close_token,
timeout_ms,
"udp data plane receive",
socket.recv_from(buf),
));
match ret {
Some(Ok((n, addr))) => {
// The returned ip pointer must be released by the caller via
// `free_string` (which calls `CString::from_raw`, matching
// `CString::into_raw` here).
let Some(ip_cstr) = into_ffi_ip_cstring(addr.ip()) else {
return -1;
};
unsafe {
*out_ip = ip_cstr as *const std::ffi::c_char;
*out_port = addr.port() as std::ffi::c_ushort;
}
n as std::ffi::c_int
}
Some(Err(e)) => {
set_error_msg(&format!("failed to receive udp data plane: {}", e));
-1
}
None => -1,
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_udp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Udp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a udp socket"
} else {
"udp socket handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
#[cfg(all(test, feature = "ffi-dataplane"))]
mod tests {
use super::*;
use std::{sync::mpsc, time::Duration};
#[test]
fn config_server_start_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _write_guard = lock_for_config_server_start().unwrap();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[test]
fn instance_cleanup_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let instance_id = Uuid::new_v4();
let (done_tx, done_rx) = mpsc::channel();
let cleaner = std::thread::spawn(move || {
remove_data_plane_handles_by_instance_ids(&[instance_id]);
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
cleaner.join().unwrap();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
use std::{
cell::RefCell,
ffi::{CString, c_char},
};
thread_local! {
// # Thread Safety
// set_error_msg and get_error_msg must be called on the same thread to
// get correct error. And since `Handle::block_on` polls the top-level
// future on the calling thread, set_error_msg always runs on the same
// thread as the corresponding get_error_msg.
static ERROR_MSG: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn set_error_msg(msg: &str) {
ERROR_MSG.with(|cell| {
let mut buf = cell.borrow_mut();
buf.clear();
buf.extend_from_slice(msg.as_bytes());
});
}
fn thread_local_error_msg() -> Option<String> {
ERROR_MSG.with(|cell| {
let buf = cell.borrow();
if buf.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
})
}
pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) {
let msg = match (
thread_local_error_msg(),
crate::config_server::last_callback_error(),
) {
(Some(error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
error, callback_error
)),
(Some(error), None) => Some(error),
(None, Some(callback_error)) => {
Some(format!("config server callback error: {}", callback_error))
}
(None, None) => None,
};
let cstr = msg.and_then(|msg| CString::new(msg).ok());
unsafe {
*out = match cstr {
Some(s) => s.into_raw() as *const c_char,
None => std::ptr::null(),
};
}
}
pub(crate) fn free_string(s: *const c_char) {
if s.is_null() {
return;
}
unsafe {
let _ = CString::from_raw(s as *mut c_char);
}
}
@@ -1,366 +0,0 @@
use std::ffi::{CString, c_char, c_int};
use easytier::common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader};
use crate::{
config_server::{
in_config_server_callback, remove_config_server_tracked_instance_ids,
wait_for_config_server_delivery,
},
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP, instance_name_exists,
lock_remote_instance_mutation,
},
types::KeyValuePair,
};
/// # Safety
/// Set the tun fd
pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
let inst_name = unsafe {
assert!(!inst_name.is_null());
std::ffi::CStr::from_ptr(inst_name)
.to_string_lossy()
.into_owned()
};
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
return -1;
}
let inst_id = *INSTANCE_NAME_ID_MAP
.get(&inst_name)
.as_ref()
.unwrap()
.value();
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
Ok(_) => 0,
Err(_) => -1,
}
}
/// # Safety
/// Parse the config
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
set_error_msg(&format!("failed to parse config: {:?}", e));
return -1;
}
0
}
/// # Safety
/// Run the network instance
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot run network instance from config server callback");
return -1;
}
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => cfg,
Err(e) => {
set_error_msg(&format!("failed to parse config: {}", e));
return -1;
}
};
let inst_name = cfg.get_inst_name();
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if instance_name_exists(&inst_name) {
set_error_msg("instance already exists");
return -1;
}
let instance_id =
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(id) => id,
Err(e) => {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
};
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
0
}
unsafe fn parse_instance_names(
inst_names: *const *const c_char,
length: usize,
) -> Option<Vec<String>> {
if length == 0 {
return Some(Vec::new());
}
if inst_names.is_null() {
set_error_msg("inst_names is null");
return None;
}
let names = unsafe { std::slice::from_raw_parts(inst_names, length) };
let mut parsed = Vec::with_capacity(length);
for (index, &name) in names.iter().enumerate() {
if name.is_null() {
set_error_msg(&format!("inst_names[{}] is null", index));
return None;
}
parsed.push(
unsafe { std::ffi::CStr::from_ptr(name) }
.to_string_lossy()
.into_owned(),
);
}
Some(parsed)
}
/// # Safety
/// Retain the network instance
pub(crate) unsafe fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot retain network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
let removed_ids = INSTANCE_MANAGER.list_network_instance_ids();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.clear();
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_none_or(|name| !inst_names.contains(&name))
})
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
0
}
/// # Safety
/// Delete named network instances.
pub(crate) unsafe fn delete_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot delete network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = inst_names
.iter()
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id.value()))
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
for name in inst_names {
INSTANCE_NAME_ID_MAP.remove(&name);
}
0
}
/// # Safety
/// Collect the network infos
pub(crate) unsafe fn collect_network_infos(
infos: *mut KeyValuePair,
max_length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot collect network infos from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
let infos = unsafe {
assert!(!infos.is_null());
std::slice::from_raw_parts_mut(infos, max_length)
};
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(e) => {
set_error_msg(&format!("failed to collect network infos: {}", e));
return -1;
}
};
let mut index = 0;
for (instance_id, value) in collected_infos.iter() {
if index >= max_length {
break;
}
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
continue;
};
// convert value to json string
let value = match serde_json::to_string(&value) {
Ok(value) => value,
Err(e) => {
set_error_msg(&format!("failed to serialize instance info: {}", e));
return -1;
}
};
infos[index] = KeyValuePair {
key: std::ffi::CString::new(key).unwrap().into_raw(),
value: std::ffi::CString::new(value).unwrap().into_raw(),
};
index += 1;
}
index as std::ffi::c_int
}
/// # Safety
/// List the instance names and IDs known by the FFI instance manager.
pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot list instances from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
if infos.is_null() {
set_error_msg("infos is null");
return -1;
}
let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) };
let mut instances = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter_map(|id| {
INSTANCE_MANAGER
.get_instance_name(&id)
.map(|name| (name, id))
})
.collect::<Vec<_>>();
instances.sort_by(|(left_name, left_id), (right_name, right_id)| {
left_name
.cmp(right_name)
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
});
let encoded_instances = match instances
.into_iter()
.take(max_length)
.map(|(name, id)| {
let key = CString::new(name)
.map_err(|err| format!("failed to encode instance name: {}", err))?;
let value = CString::new(id.to_string())
.map_err(|err| format!("failed to encode instance id: {}", err))?;
Ok((key, value))
})
.collect::<Result<Vec<_>, String>>()
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let count = encoded_instances.len();
for (index, (key, value)) in encoded_instances.into_iter().enumerate() {
infos[index] = KeyValuePair {
key: key.into_raw(),
value: value.into_raw(),
};
}
count as std::ffi::c_int
}
@@ -1,100 +0,0 @@
use std::ffi::{CString, c_char, c_int};
use crate::{
config_server::in_config_server_callback,
error::set_error_msg,
state::{ASYNC_RUNTIME, INSTANCE_MANAGER},
strings::{c_str_to_string, optional_c_str_to_string},
};
/// # Safety
/// See `crate::call_json_rpc`.
pub(crate) unsafe fn call_json_rpc(
service_name: *const c_char,
method_name: *const c_char,
domain_name: *const c_char,
payload_json: *const c_char,
out_response_json: *mut *const c_char,
) -> c_int {
if out_response_json.is_null() {
set_error_msg("out_response_json is null");
return -1;
}
unsafe {
*out_response_json = std::ptr::null();
}
if in_config_server_callback() {
set_error_msg("cannot call JSON RPC from config server callback");
return -1;
}
let service_name = match unsafe { c_str_to_string(service_name, "service_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let method_name = match unsafe { c_str_to_string(method_name, "method_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload = match serde_json::from_str::<serde_json::Value>(&payload_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to parse payload_json: {}", err));
return -1;
}
};
let response = match ASYNC_RUNTIME.block_on(easytier::rpc_service::call_json_rpc(
&INSTANCE_MANAGER,
&service_name,
&method_name,
domain_name.as_deref(),
payload,
)) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("RPC Error: {}", err));
return -1;
}
};
let response_json = match serde_json::to_string(&response) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to serialize RPC response: {}", err));
return -1;
}
};
let response_json = match CString::new(response_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to allocate RPC response: {}", err));
return -1;
}
};
unsafe {
*out_response_json = response_json.into_raw();
}
0
}
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use easytier::instance_manager::NetworkInstanceManager;
use tokio::runtime::{Builder, Runtime};
use uuid::Uuid;
pub(crate) static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, Uuid>> =
once_cell::sync::Lazy::new(DashMap::new);
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
pub(crate) static ASYNC_RUNTIME: once_cell::sync::Lazy<Runtime> =
once_cell::sync::Lazy::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ffi")
});
pub(crate) static INSTANCE_MUTATION_LOCK: once_cell::sync::Lazy<Mutex<()>> =
once_cell::sync::Lazy::new(|| Mutex::new(()));
pub(crate) fn remove_instance_name_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
INSTANCE_NAME_ID_MAP.retain(|_, instance_id| !ids.contains(instance_id));
}
pub(crate) fn lock_remote_instance_mutation() -> tokio::sync::OwnedMutexGuard<()> {
INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned()
}
pub(crate) fn instance_name_exists(inst_name: &str) -> bool {
find_instance_id_by_name(inst_name).is_some()
}
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<Uuid> {
INSTANCE_NAME_ID_MAP
.get(inst_name)
.map(|id| *id)
.or_else(|| {
INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.find(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_some_and(|name| name == inst_name)
})
})
}
@@ -1,23 +0,0 @@
use std::ffi::{CStr, c_char};
pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, String> {
if ptr.is_null() {
return Err(format!("{} is null", name));
}
unsafe { CStr::from_ptr(ptr) }
.to_str()
.map(|value| value.to_string())
.map_err(|err| format!("{} is not valid UTF-8: {}", name, err))
}
pub(crate) unsafe fn optional_c_str_to_string(
ptr: *const c_char,
name: &str,
) -> Result<Option<String>, String> {
if ptr.is_null() {
return Ok(None);
}
unsafe { c_str_to_string(ptr, name) }.map(Some)
}
-766
View File
@@ -1,766 +0,0 @@
use crate::{
config_server::{
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
},
state::{
INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP, find_instance_id_by_name,
lock_remote_instance_mutation, remove_instance_name_ids,
},
*,
};
use easytier::{
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
web_client::WebClientHooks,
};
use serde_json::Value;
use std::{
collections::HashSet,
ffi::{CStr, CString, c_char, c_void},
sync::{Mutex, mpsc},
time::Duration,
};
use uuid::Uuid;
#[test]
fn test_parse_config() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(parse_config(cstr.as_ptr()), 0);
}
}
#[test]
fn test_run_network_instance() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
}
}
#[test]
fn get_error_msg_returns_config_server_callback_error() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let callback_error = format!("callback delivery failed {}", Uuid::new_v4());
crate::config_server::clear_last_callback_error();
hooks.note_callback_error(callback_error.clone());
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
assert!(!error_ptr.is_null());
let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
assert!(error_msg.contains(&callback_error));
}
crate::config_server::clear_last_callback_error();
}
unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) {
let events = unsafe { &*(user_data as *const Mutex<Vec<String>>) };
events.lock().unwrap().push(
unsafe { CStr::from_ptr(event_json) }
.to_string_lossy()
.into_owned(),
);
}
fn take_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
Some(error)
}
}
}
fn free_key_value_pairs(infos: &[KeyValuePair]) {
for info in infos {
free_string(info.key);
free_string(info.value);
}
}
#[test]
fn list_instance_returns_instance_names_and_ids() {
let instance_id = Uuid::new_v4();
let instance_name = format!("list-instance-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(instance_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(instance_name.clone(), instance_id);
let mut infos = vec![
KeyValuePair {
key: std::ptr::null(),
value: std::ptr::null(),
};
16
];
let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) };
assert!(count > 0);
let mut found = false;
for info in infos.iter().take(count as usize) {
let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy();
let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy();
if key == instance_name {
assert_eq!(value, instance_id.to_string());
found = true;
}
}
free_key_value_pairs(&infos[..count as usize]);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
assert!(found);
}
#[test]
fn list_instance_allows_zero_length() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0);
}
#[test]
fn list_instance_rejects_null_output_pointer() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1);
assert!(take_last_error().unwrap().contains("infos is null"));
}
#[test]
fn call_json_rpc_returns_logger_response() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
0
);
assert!(!response_ptr.is_null());
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
let response: Value = serde_json::from_str(&response).unwrap();
assert!(response.get("level").is_some());
}
#[test]
fn call_json_rpc_rejects_instance_management_service() {
let service = CString::new("api.manage.WebClientService").unwrap();
let method = CString::new("list_network_instance").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(take_last_error().unwrap().contains("not exposed"));
}
#[test]
fn call_json_rpc_rejects_malformed_payload_json() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(
take_last_error()
.unwrap()
.contains("failed to parse payload_json")
);
}
#[test]
fn call_json_rpc_rejects_null_output_pointer() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
std::ptr::null_mut(),
)
},
-1
);
assert!(
take_last_error()
.unwrap()
.contains("out_response_json is null")
);
}
#[tokio::test]
async fn config_server_hooks_emit_run_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
let inst_name = format!("test-{}", instance_id);
cfg.set_inst_name(inst_name.clone());
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
hooks.post_run_network_instance(&instance_id).await.unwrap();
let duplicate_cfg = TomlConfigLoader::default();
duplicate_cfg.set_inst_name(inst_name);
duplicate_cfg.set_id(Uuid::new_v4());
assert!(
hooks
.pre_run_network_instance(&duplicate_cfg)
.await
.is_err()
);
assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]);
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
let event: Value = serde_json::from_str(&events[0]).unwrap();
assert_eq!(event["event"], "run_network_instance");
assert_eq!(event["success"], true);
assert_eq!(event["instance_id"], instance_id.to_string());
assert!(event["error"].is_null());
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[tokio::test]
async fn config_server_hooks_emit_delete_events_for_tracked_instances() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id_1 = Uuid::new_v4();
let instance_id_2 = Uuid::new_v4();
let unknown_instance_id = Uuid::new_v4();
for id in [instance_id_1, instance_id_2] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(format!("test-{}", id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
hooks
.post_run_network_instance(&instance_id_1)
.await
.unwrap();
hooks
.post_run_network_instance(&instance_id_2)
.await
.unwrap();
events.lock().unwrap().clear();
hooks
.post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2])
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
let event_ids = events
.iter()
.map(|event| {
let event: Value = serde_json::from_str(event).unwrap();
assert_eq!(event["event"], "delete_network_instance");
assert_eq!(event["success"], true);
assert!(event["error"].is_null());
event["instance_id"].as_str().unwrap().to_string()
})
.collect::<HashSet<_>>();
assert_eq!(
event_ids,
HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()])
);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id_1, instance_id_2])
.unwrap();
remove_instance_name_ids(&[instance_id_1, instance_id_2]);
}
#[tokio::test]
async fn config_server_hooks_remove_untracked_name_mapping_without_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let local_id = Uuid::new_v4();
let inst_name = format!("local-{}", local_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), local_id);
hooks
.post_remove_network_instances(&[local_id])
.await
.unwrap();
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
assert!(events.lock().unwrap().is_empty());
}
#[tokio::test]
async fn config_server_hooks_reject_duplicate_instance_name() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let existing_id = Uuid::new_v4();
let new_id = Uuid::new_v4();
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), existing_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(new_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert_eq!(*INSTANCE_NAME_ID_MAP.get(&inst_name).unwrap(), existing_id);
INSTANCE_NAME_ID_MAP.remove(&inst_name);
}
#[tokio::test]
async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let old_name = format!("old-{}", Uuid::new_v4());
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
let overwritten_id = Uuid::new_v4();
let duplicate_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(overwritten_id);
INSTANCE_NAME_ID_MAP.insert(old_name.clone(), overwritten_id);
INSTANCE_NAME_ID_MAP.insert(duplicate_name.clone(), duplicate_id);
hooks
.post_remove_network_instances(&[overwritten_id])
.await
.unwrap();
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(duplicate_name.clone());
cfg.set_id(overwritten_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&old_name).is_none());
assert_eq!(
*INSTANCE_NAME_ID_MAP.get(&duplicate_name).unwrap(),
duplicate_id
);
assert_eq!(events.lock().unwrap().len(), 1);
INSTANCE_NAME_ID_MAP.remove(&duplicate_name);
}
#[tokio::test]
async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let instance_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(instance_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(instance_id);
hooks
.post_remove_network_instances(&[instance_id])
.await
.unwrap();
hooks.pre_run_network_instance(&cfg).await.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
}
#[tokio::test]
async fn config_server_hooks_reject_post_run_after_external_delete() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(format!("test-{}", instance_id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
assert!(hooks.post_run_network_instance(&instance_id).await.is_err());
}
#[test]
fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() {
let instance_id = Uuid::new_v4();
let inst_name = format!("test-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(inst_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[test]
fn delete_network_instance_removes_only_named_instances() {
let keep_id = Uuid::new_v4();
let delete_id = Uuid::new_v4();
let keep_name = format!("keep-{}", keep_id);
let delete_name = format!("delete-{}", delete_id);
for (id, name) in [
(keep_id, keep_name.clone()),
(delete_id, delete_name.clone()),
] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(name, id);
}
let delete_name = CString::new(delete_name.clone()).unwrap();
let inst_names = [delete_name.as_ptr()];
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
0
);
assert_eq!(find_instance_id_by_name(&keep_name), Some(keep_id));
assert!(find_instance_id_by_name(delete_name.to_str().unwrap()).is_none());
INSTANCE_MANAGER
.delete_network_instance(vec![keep_id])
.unwrap();
remove_instance_name_ids(&[keep_id]);
}
#[test]
fn retain_and_delete_network_instance_reject_invalid_name_pointers() {
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 1) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 1) }, -1);
let inst_names = [std::ptr::null()];
assert_eq!(
unsafe { retain_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
}
#[test]
fn ffi_remote_mutation_lock_uses_manager_lock() {
let manager_guard = INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ffi_guard = lock_remote_instance_mutation();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(manager_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[tokio::test]
async fn config_server_hooks_suppress_late_run_events_while_stopping() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
hooks.start_stopping();
hooks
.post_run_network_instance(&Uuid::new_v4())
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[test]
fn config_server_callback_context_rejects_nested_blocking_ffi_calls() {
let _callback_scope = ConfigServerCallbackScope::enter();
assert_eq!(is_config_server_client_connected(), 0);
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert_eq!(
unsafe { collect_network_infos(std::ptr::null_mut(), 0) },
-1
);
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, -1);
let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap();
assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1);
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 0) }, -1);
let url = CString::new("ring://test/token").unwrap();
let machine_id = CString::new("test-machine").unwrap();
assert_eq!(
unsafe {
start_config_server_client(
url.as_ptr(),
std::ptr::null(),
machine_id.as_ptr(),
false,
None,
std::ptr::null_mut(),
)
},
-1
);
assert_eq!(stop_config_server_client(), -1);
#[cfg(feature = "ffi-dataplane")]
{
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_accept(
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_write(0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(data_plane_tcp_close(0), -1);
assert_eq!(data_plane_tcp_listener_close(0), -1);
assert_eq!(
unsafe {
data_plane_udp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_udp_send_to(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(
unsafe {
data_plane_udp_recv_from(
0,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
)
},
-1
);
assert_eq!(data_plane_udp_close(0), -1);
assert_eq!(data_plane_async_op_status(0), -2);
assert_eq!(data_plane_async_op_wait(0, 0), -2);
assert_eq!(data_plane_async_op_cancel(0), -2);
assert_eq!(data_plane_async_op_free(0), -2);
data_plane_free_bytes(std::ptr::null(), 0);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_tcp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_accept_start(0, 0) }, 0);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
assert_eq!(
unsafe { data_plane_tcp_write_start(0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_send_to_start(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_udp_recv_from_start(0, 0, 0) }, 0);
}
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn active_config_server_rejects_data_plane() {
set_active_for_test(true);
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
set_active_for_test(false);
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn async_op_invalid_handle_helpers_are_stable() {
assert_eq!(data_plane_async_op_status(u64::MAX), -2);
assert_eq!(data_plane_async_op_wait(u64::MAX, 1), -2);
assert_eq!(data_plane_async_op_cancel(u64::MAX), -2);
assert_eq!(data_plane_async_op_free(u64::MAX), -2);
data_plane_free_bytes(std::ptr::null(), 0);
}
@@ -1,10 +0,0 @@
use std::ffi::{c_char, c_void};
#[repr(C)]
#[derive(Clone, Copy)]
pub struct KeyValuePair {
pub key: *const c_char,
pub value: *const c_char,
}
pub type ConfigServerEventCallback = Option<unsafe extern "C" fn(*const c_char, *mut c_void)>;
+64 -112
View File
@@ -1237,18 +1237,18 @@ dependencies = [
"ordered_hash_map",
"parking_lot",
"paste",
"pbjson",
"pbjson-build",
"percent-encoding",
"petgraph",
"petgraph 0.8.2",
"pin-project-lite",
"pnet",
"prefix-trie",
"proc-macro2",
"prost 0.14.3",
"prost",
"prost-build",
"prost-reflect 0.16.4",
"prost-reflect",
"prost-reflect-build",
"prost-wkt",
"prost-wkt-build",
"prost-wkt-types",
"quinn",
"quinn-plaintext",
@@ -1318,8 +1318,9 @@ dependencies = [
"napi-build-ohos",
"napi-derive-ohos",
"napi-ohos",
"ohos-hilog-binding",
"once_cell",
"prost-reflect 0.14.7",
"prost-reflect",
"rusqlite",
"serde",
"serde_json",
@@ -3164,6 +3165,22 @@ dependencies = [
"libc",
]
[[package]]
name = "ohos-hilog-binding"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "860d1e3c2c5e3217d819a16c815d2d4dcbc7610285d2612d08745a29c353a503"
dependencies = [
"libc",
"ohos-hilogs-sys",
]
[[package]]
name = "ohos-hilogs-sys"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed07615005d0f8d7bcf901f89c8ff4870666a9bdb00382f588af383f40c160b7"
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -3291,28 +3308,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbjson"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "pbjson-build"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"prost 0.14.3",
"prost-types 0.14.3",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -3339,6 +3334,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "petgraph"
version = "0.8.2"
@@ -3626,33 +3631,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive 0.14.3",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.3"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
"petgraph",
"once_cell",
"petgraph 0.7.1",
"prettyplease",
"prost 0.14.3",
"prost-types 0.14.3",
"prost",
"prost-types",
"regex",
"syn 2.0.106",
"tempfile",
@@ -3671,19 +3667,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "prost-reflect"
version = "0.14.7"
@@ -3691,30 +3674,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
dependencies = [
"once_cell",
"prost 0.13.5",
"prost-reflect-derive 0.14.0",
"prost-types 0.13.5",
]
[[package]]
name = "prost-reflect"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
dependencies = [
"prost 0.14.3",
"prost-reflect-derive 0.16.0",
"prost-types 0.14.3",
"prost",
"prost-reflect-derive",
"prost-types",
]
[[package]]
name = "prost-reflect-build"
version = "0.16.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8214ae2c30bbac390db0134d08300e770ef89b6d4e5abf855e8d300eded87e28"
checksum = "50e2537231d94dd2778920c2ada37dd9eb1ac0325bb3ee3ee651bd44c1134123"
dependencies = [
"prost-build",
"prost-reflect 0.16.4",
"prost-reflect",
]
[[package]]
@@ -3728,44 +3700,24 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "prost-reflect-derive"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b6d90e29fa6c0d13c2c19ba5e4b3fb0efbf5975d27bcf4e260b7b15455bcabe"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost 0.14.3",
"prost",
]
[[package]]
name = "prost-wkt"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3de5e9c9e84fcb5efa204b8e283d23e615a8bc8c777bf1d6622bb01dc61445"
checksum = "497e1e938f0c09ef9cabe1d49437b4016e03e8f82fbbe5d1c62a9b61b9decae1"
dependencies = [
"chrono",
"inventory",
"prost 0.14.3",
"prost",
"serde",
"serde_derive",
"serde_json",
@@ -3774,27 +3726,27 @@ dependencies = [
[[package]]
name = "prost-wkt-build"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe500dc80e757a75e1e8fb7290e448d62dfba3105ece1d058579cb00b58151cd"
checksum = "07b8bf115b70a7aa5af1fd5d6e9418492e9ccb6e4785e858c938e28d132a884b"
dependencies = [
"heck 0.5.0",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"quote",
]
[[package]]
name = "prost-wkt-types"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13807eaa7e15833d06e899008371926201cdcd11d74b6d490f49130cdb3f415e"
checksum = "c8cdde6df0a98311c839392ca2f2f0bcecd545f86a62b4e3c6a49c336e970fe5"
dependencies = [
"chrono",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"prost-wkt",
"prost-wkt-build",
"regex",
@@ -3883,9 +3835,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
@@ -4022,9 +3974,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -4034,9 +3986,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
@@ -11,6 +11,7 @@ async-trait = "0.1"
base64 = "0.22"
flate2 = "1.1"
gethostname = "1.1"
ohos-hilog-binding = {version = "*", features = ["redirect"]}
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
@@ -1,49 +1,13 @@
use crate::config::types::stored_config::{
SnapshotImportResult, StoredConfigList, StoredConfigMeta,
};
use once_cell::sync::Lazy;
use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta};
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::HashSet;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
static CONFIG_DB_CONNECTION: Lazy<Mutex<Option<CachedConfigDb>>> = Lazy::new(|| Mutex::new(None));
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
struct CachedConfigDb {
path: PathBuf,
conn: Connection,
}
pub(crate) struct ConfigDbGuard<'a> {
guard: MutexGuard<'a, Option<CachedConfigDb>>,
}
impl Deref for ConfigDbGuard<'_> {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self
.guard
.as_ref()
.expect("config db connection guard must contain a connection")
.conn
}
}
impl DerefMut for ConfigDbGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self
.guard
.as_mut()
.expect("config db connection guard must contain a connection")
.conn
}
}
#[derive(Debug, Clone)]
struct StoredConfigMetaRecord {
config_id: String,
@@ -54,30 +18,6 @@ struct StoredConfigMetaRecord {
temporary: bool,
}
type SnapshotFieldRow = (String, String, String, String);
fn snapshot_import_ok() -> SnapshotImportResult {
SnapshotImportResult {
ok: true,
error_code: String::new(),
error_message: String::new(),
snapshot_invalid: false,
}
}
fn snapshot_import_err(
error_code: &str,
error_message: impl Into<String>,
snapshot_invalid: bool,
) -> SnapshotImportResult {
SnapshotImportResult {
ok: false,
error_code: error_code.to_string(),
error_message: error_message.into(),
snapshot_invalid,
}
}
pub(crate) fn now_ts_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -113,176 +53,31 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
);
CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id
ON stored_config_fields(config_id);",
)?;
ensure_column(
conn,
"stored_configs",
"favorite",
"ALTER TABLE stored_configs ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_configs",
"temporary",
"ALTER TABLE stored_configs ADD COLUMN temporary INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_config_fields",
"updated_at",
"ALTER TABLE stored_config_fields ADD COLUMN updated_at TEXT NOT NULL DEFAULT '0';",
)?;
if !validate_store_schema(conn)? {
return Err(rusqlite::Error::InvalidQuery);
}
conn.execute_batch("PRAGMA user_version = 1;")
)
}
fn table_columns(conn: &Connection, table_name: &str) -> rusqlite::Result<HashSet<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table_name))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
let mut columns = HashSet::new();
for row in rows {
columns.insert(row?);
}
Ok(columns)
}
fn ensure_column(
conn: &Connection,
table_name: &str,
column_name: &str,
alter_sql: &str,
) -> rusqlite::Result<()> {
let columns = table_columns(conn, table_name)?;
if !columns.contains(column_name) {
conn.execute_batch(alter_sql)?;
}
Ok(())
}
fn validate_store_schema(conn: &Connection) -> rusqlite::Result<bool> {
let meta_columns = table_columns(conn, "stored_configs")?;
let field_columns = table_columns(conn, "stored_config_fields")?;
let required_meta = [
"config_id",
"display_name",
"created_at",
"updated_at",
"favorite",
"temporary",
];
let required_fields = ["config_id", "field_name", "field_json", "updated_at"];
Ok(required_meta
.iter()
.all(|column| meta_columns.contains(*column))
&& required_fields
.iter()
.all(|column| field_columns.contains(*column)))
}
fn move_db_file_if_exists(path: &Path) -> bool {
if !path.exists() {
return true;
}
let target = PathBuf::from(format!(
"{}.corrupt.{}",
path.to_string_lossy(),
now_ts_string()
));
match std::fs::rename(path, &target) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to move corrupt config db {} to {}: {}",
path.display(),
target.display(),
e
);
false
}
}
}
fn recover_config_db_files(path: &Path) -> bool {
let main_ok = move_db_file_if_exists(path);
let wal_ok = move_db_file_if_exists(Path::new(&format!("{}-wal", path.to_string_lossy())));
let shm_ok = move_db_file_if_exists(Path::new(&format!("{}-shm", path.to_string_lossy())));
main_ok && wal_ok && shm_ok
}
fn open_connection(path: &Path) -> Option<Connection> {
let conn = match Connection::open(path) {
pub(crate) fn open_db() -> Option<Connection> {
let path = db_file_path()?;
let conn = match Connection::open(&path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!("[Rust] failed to open config db {}: {}", path.display(), e);
hilog_error!("[Rust] failed to open config db {}: {}", path.display(), e);
return None;
}
};
if let Err(e) = init_schema(&conn) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to initialize config db {}: {}",
path.display(),
e
);
drop(conn);
if !recover_config_db_files(path) {
return None;
}
let recovered = match Connection::open(path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open recovered config db {}: {}",
path.display(),
e
);
return None;
}
};
if let Err(e) = init_schema(&recovered) {
ohrs_log_error!(
"[Rust] failed to initialize recovered config db {}: {}",
path.display(),
e
);
return None;
}
return Some(recovered);
return None;
}
Some(conn)
}
pub(crate) fn open_db() -> Option<ConfigDbGuard<'static>> {
let path = db_file_path()?;
let mut guard = match CONFIG_DB_CONNECTION.lock() {
Ok(guard) => guard,
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config db connection: {}", e);
return None;
}
};
let should_open = guard
.as_ref()
.map(|cached| cached.path != path || !cached.path.exists())
.unwrap_or(true);
if should_open {
let conn = open_connection(&path)?;
*guard = Some(CachedConfigDb { path, conn });
}
Some(ConfigDbGuard { guard })
}
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
Ok(StoredConfigMetaRecord {
config_id: row.get(0)?,
@@ -330,64 +125,38 @@ fn validate_snapshot_schema(conn: &Connection) -> bool {
has_stored_configs && has_stored_fields
}
fn read_snapshot_tables(
src: &Connection,
) -> rusqlite::Result<(Vec<StoredConfigMetaRecord>, Vec<SnapshotFieldRow>)> {
src.execute_batch("BEGIN DEFERRED TRANSACTION")?;
fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> {
let mut meta_rows = Vec::<StoredConfigMetaRecord>::new();
let mut field_rows = Vec::<SnapshotFieldRow>::new();
let read_result = (|| -> rusqlite::Result<()> {
{
let mut stmt = src.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs",
)?;
let rows = stmt.query_map([], row_to_meta)?;
for row in rows {
meta_rows.push(row?);
}
}
{
let mut stmt = src.prepare(
"SELECT config_id, field_name, field_json, updated_at
FROM stored_config_fields",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
field_rows.push(row?);
}
{
let mut stmt = src.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs",
)?;
let rows = stmt.query_map([], row_to_meta)?;
for row in rows {
meta_rows.push(row?);
}
}
Ok(())
})();
match read_result {
Ok(()) => {
src.execute_batch("COMMIT")?;
Ok((meta_rows, field_rows))
}
Err(err) => {
let _ = src.execute_batch("ROLLBACK");
Err(err)
let mut field_rows = Vec::<(String, String, String, String)>::new();
{
let mut stmt = src.prepare(
"SELECT config_id, field_name, field_json, updated_at
FROM stored_config_fields",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
field_rows.push(row?);
}
}
}
fn write_snapshot_tables(
dst: &mut Connection,
meta_rows: Vec<StoredConfigMetaRecord>,
field_rows: Vec<SnapshotFieldRow>,
) -> rusqlite::Result<()> {
let tx = dst.unchecked_transaction()?;
tx.execute("DELETE FROM stored_config_fields", [])?;
tx.execute("DELETE FROM stored_configs", [])?;
@@ -419,17 +188,12 @@ fn write_snapshot_tables(
tx.commit()
}
fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> {
let (meta_rows, field_rows) = read_snapshot_tables(src)?;
write_snapshot_tables(dst, meta_rows, field_rows)
}
fn ensure_parent_dir(path: &Path) -> bool {
match path.parent() {
Some(parent) => match std::fs::create_dir_all(parent) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to create snapshot parent {}: {}",
parent.display(),
e
@@ -455,7 +219,7 @@ fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
pub fn init_config_meta_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
if let Err(e) = std::fs::create_dir_all(&root) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to create config db dir {}: {}",
root.display(),
e
@@ -469,7 +233,7 @@ pub fn init_config_meta_store(root_dir: String) -> bool {
*guard = Some(db_path.clone());
}
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config db path: {}", e);
hilog_error!("[Rust] failed to lock config db path: {}", e);
return false;
}
}
@@ -478,7 +242,7 @@ pub fn init_config_meta_store(root_dir: String) -> bool {
return false;
}
ohrs_log_debug!("[Rust] initialized config db at {}", db_path.display());
hilog_debug!("[Rust] initialized config db at {}", db_path.display());
true
}
@@ -493,7 +257,7 @@ pub fn export_config_store_snapshot(target_path: String) -> bool {
let mut dst = match Connection::open(&target) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to open snapshot target {}: {}",
target.display(),
e
@@ -502,7 +266,7 @@ pub fn export_config_store_snapshot(target_path: String) -> bool {
}
};
if let Err(e) = init_schema(&dst) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to init snapshot schema {}: {}",
target.display(),
e
@@ -512,7 +276,7 @@ pub fn export_config_store_snapshot(target_path: String) -> bool {
match copy_snapshot_tables(&src, &mut dst) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to export snapshot {}: {}",
target.display(),
e
@@ -522,92 +286,34 @@ pub fn export_config_store_snapshot(target_path: String) -> bool {
}
}
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
pub fn import_config_store_snapshot(source_path: String) -> bool {
let source = PathBuf::from(source_path);
let src = match Connection::open(&source) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to open snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("source_open_failed", e.to_string(), false);
}
};
if !validate_snapshot_schema(&src) {
ohrs_log_error!("[Rust] invalid snapshot schema {}", source.display());
return snapshot_import_err(
"invalid_snapshot_schema",
format!("invalid snapshot schema: {}", source.display()),
true,
);
}
let (meta_rows, field_rows) = match read_snapshot_tables(&src) {
Ok(rows) => rows,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to read snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("invalid_snapshot_data", e.to_string(), true);
}
};
let Some(mut dst) = open_db() else {
return snapshot_import_err(
"destination_open_failed",
"failed to open local config store",
false,
);
};
match write_snapshot_tables(&mut dst, meta_rows, field_rows) {
Ok(_) => snapshot_import_ok(),
Err(e) => {
ohrs_log_error!(
"[Rust] failed to import snapshot {}: {}",
source.display(),
e
);
snapshot_import_err("destination_write_failed", e.to_string(), false)
}
}
}
pub fn import_config_store_snapshot(source_path: String) -> bool {
import_config_store_snapshot_with_result(source_path).ok
}
pub fn reset_config_meta_store() -> bool {
let Some(conn) = open_db() else {
return false;
};
let tx = match conn.unchecked_transaction() {
Ok(tx) => tx,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to start config store reset transaction: {}",
e
);
return false;
}
};
if let Err(e) = tx.execute("DELETE FROM stored_config_fields", []) {
ohrs_log_error!("[Rust] failed to reset config fields: {}", e);
let _ = tx.rollback();
if !validate_snapshot_schema(&src) {
hilog_error!("[Rust] invalid snapshot schema {}", source.display());
return false;
}
if let Err(e) = tx.execute("DELETE FROM stored_configs", []) {
ohrs_log_error!("[Rust] failed to reset config meta: {}", e);
let _ = tx.rollback();
let Some(mut dst) = open_db() else {
return false;
}
match tx.commit() {
};
match copy_snapshot_tables(&src, &mut dst) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!("[Rust] failed to commit config store reset: {}", e);
hilog_error!(
"[Rust] failed to import snapshot {}: {}",
source.display(),
e
);
false
}
}
@@ -625,7 +331,7 @@ pub fn list_config_meta_entries() -> StoredConfigList {
) {
Ok(stmt) => stmt,
Err(e) => {
ohrs_log_error!("[Rust] failed to prepare list meta query: {}", e);
hilog_error!("[Rust] failed to prepare list meta query: {}", e);
return StoredConfigList { configs: vec![] };
}
};
@@ -633,7 +339,7 @@ pub fn list_config_meta_entries() -> StoredConfigList {
let rows = match stmt.query_map([], row_to_meta) {
Ok(rows) => rows,
Err(e) => {
ohrs_log_error!("[Rust] failed to list config meta rows: {}", e);
hilog_error!("[Rust] failed to list config meta rows: {}", e);
return StoredConfigList { configs: vec![] };
}
};
@@ -652,6 +358,59 @@ pub fn get_config_meta(config_id: &str) -> Option<StoredConfigMeta> {
load_meta_record(&conn, config_id).map(to_meta)
}
pub fn upsert_config_meta(
config_id: String,
display_name: String,
favorite: bool,
temporary: bool,
) -> StoredConfigMeta {
let now = now_ts_string();
let Some(conn) = open_db() else {
return StoredConfigMeta {
config_id,
display_name,
created_at: now.clone(),
updated_at: now,
favorite,
temporary,
};
};
let created_at = load_meta_record(&conn, &config_id)
.map(|record| record.created_at)
.unwrap_or_else(|| now.clone());
if let Err(e) = conn.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(config_id) DO UPDATE SET
display_name = excluded.display_name,
updated_at = excluded.updated_at,
favorite = excluded.favorite,
temporary = excluded.temporary",
params![
config_id,
display_name,
created_at,
now,
if favorite { 1 } else { 0 },
if temporary { 1 } else { 0 }
],
) {
hilog_error!("[Rust] failed to upsert config meta: {}", e);
}
get_config_meta(&config_id).unwrap_or(StoredConfigMeta {
config_id,
display_name,
created_at,
updated_at: now,
favorite,
temporary,
})
}
pub(crate) fn upsert_config_meta_in_tx(
tx: &rusqlite::Transaction<'_>,
config_id: String,
@@ -733,45 +492,19 @@ pub fn set_config_display_name(
Some(to_meta(record))
}
pub fn set_config_favorite(config_id: String, favorite: bool) -> Option<StoredConfigMeta> {
let conn = open_db()?;
let now = now_ts_string();
let tx = conn.unchecked_transaction().ok()?;
pub fn delete_config_meta(config_id: &str) -> bool {
let Some(conn) = open_db() else {
return false;
};
if favorite {
tx.execute(
"UPDATE stored_configs
SET favorite = 0,
updated_at = CASE WHEN favorite != 0 THEN ?1 ELSE updated_at END
WHERE favorite != 0 AND config_id <> ?2",
params![now, config_id.clone()],
)
.ok()?;
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
hilog_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
let rows = tx
.execute(
"UPDATE stored_configs
SET favorite = ?2, updated_at = ?3
WHERE config_id = ?1",
params![config_id.clone(), if favorite { 1 } else { 0 }, now],
)
.ok()?;
if rows == 0 {
return None;
}
let meta = tx
.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(to_meta)?;
tx.commit().ok()?;
Some(meta)
}
@@ -35,6 +35,14 @@ pub struct ExportTomlResult {
pub toml_text: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigSummary {
pub config_id: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
@@ -58,13 +66,3 @@ pub struct KeyValuePair {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct SnapshotImportResult {
pub ok: bool,
pub error_code: String,
pub error_message: String,
pub snapshot_invalid: bool,
}
+31 -139
View File
@@ -1,74 +1,21 @@
use super::{field_store, import_export, legacy_migration, validation};
use crate::config::storage::config_meta::{
get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
reset_config_meta_store, upsert_config_meta_in_tx,
delete_config_meta, get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
upsert_config_meta_in_tx,
};
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::ConfigLoader;
use easytier::proto::api::manage::NetworkConfig;
use once_cell::sync::Lazy;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::params;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
static RUNTIME_CONFIG_SNAPSHOTS: Lazy<Mutex<HashMap<String, RuntimeConfigSnapshot>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
#[derive(Clone)]
pub(crate) struct RuntimeConfigSnapshot {
pub display_name: String,
pub config: NetworkConfig,
}
pub(crate) fn cache_runtime_config_snapshot(
config_id: String,
display_name: String,
config: NetworkConfig,
) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.insert(
config_id,
RuntimeConfigSnapshot {
display_name,
config,
},
);
}
}
pub(crate) fn clear_runtime_config_snapshot(config_id: &str) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.remove(config_id);
}
}
pub(crate) fn get_runtime_config_snapshot(config_id: &str) -> Option<RuntimeConfigSnapshot> {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| guard.get(config_id).cloned())
}
pub(crate) fn get_runtime_config_route_overrides(config_id: &str) -> (Vec<String>, Vec<String>) {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| {
guard.get(config_id).map(|snapshot| {
(
snapshot.config.routes.clone(),
snapshot.config.proxy_cidrs.clone(),
)
})
})
.unwrap_or_default()
}
pub(crate) fn config_root_dir() -> Option<PathBuf> {
CONFIG_ROOT_DIR
.lock()
@@ -88,7 +35,7 @@ pub fn init_config_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
let configs_dir = root.join(CONFIG_DIR_NAME);
if let Err(e) = std::fs::create_dir_all(&configs_dir) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to create config dir {}: {}",
configs_dir.display(),
e
@@ -101,7 +48,7 @@ pub fn init_config_store(root_dir: String) -> bool {
*guard = Some(root.clone());
}
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config root dir: {}", e);
hilog_error!("[Rust] failed to lock config root dir: {}", e);
return false;
}
}
@@ -110,27 +57,14 @@ pub fn init_config_store(root_dir: String) -> bool {
return false;
}
ohrs_log_debug!(
hilog_debug!(
"[Rust] initialized config repo at {}",
configs_dir.display()
);
true
}
pub fn reset_config_store() -> bool {
if !reset_config_meta_store() {
return false;
}
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.clear();
}
true
}
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
if validation::validate_config_id(config_id).is_err() {
return None;
}
legacy_migration::migrate_legacy_file_if_needed(
&config_root_dir(),
CONFIG_DIR_NAME,
@@ -147,7 +81,7 @@ pub fn save_config_record(
let config = match validation::validate_config_json(&config_json, config_id.clone()) {
Ok(config) => config,
Err(e) => {
ohrs_log_error!("[Rust] save_config_record failed {}", e);
hilog_error!("[Rust] save_config_record failed {}", e);
return None;
}
};
@@ -155,7 +89,7 @@ pub fn save_config_record(
let normalized_json = match serde_json::to_string(&config) {
Ok(raw) => raw,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to serialize normalized config {}: {}",
config_id,
e
@@ -171,15 +105,15 @@ pub fn save_config_record(
let conn = open_db()?;
let tx = conn.unchecked_transaction().ok()?;
let existing_meta = tx
.query_row(
"SELECT favorite, temporary FROM stored_configs WHERE config_id = ?1",
params![config_id.clone()],
|row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, i64>(1)? != 0)),
)
.ok();
let favorite = existing_meta.map(|meta| meta.0).unwrap_or(false);
let temporary = existing_meta.map(|meta| meta.1).unwrap_or(false);
let existing_meta = get_config_meta(&config_id);
let favorite = existing_meta
.as_ref()
.map(|meta| meta.favorite)
.unwrap_or(false);
let temporary = existing_meta
.as_ref()
.map(|meta| meta.temporary)
.unwrap_or(false);
let meta = upsert_config_meta_in_tx(&tx, config_id.clone(), display_name, favorite, temporary)?;
field_store::replace_config_fields(&tx, &config_id, fields)?;
@@ -199,52 +133,30 @@ pub fn save_config_record(
}
pub fn load_config_json(config_id: &str) -> Option<String> {
validation::validate_config_id(config_id).ok()?;
migrate_legacy_file_if_needed(config_id)?;
let object = field_store::load_config_map_from_db(config_id)?;
serde_json::to_string(&Value::Object(object)).ok()
}
pub fn get_config_record(config_id: &str) -> Option<StoredConfigRecord> {
validation::validate_config_id(config_id).ok()?;
let config_json = load_config_json(config_id)?;
let meta = get_config_meta(config_id)?;
Some(StoredConfigRecord { meta, config_json })
}
pub fn get_config_field_value(config_id: &str, field: &str) -> Option<String> {
let total_start = Instant::now();
validation::validate_config_id(config_id).ok()?;
migrate_legacy_file_if_needed(config_id)?;
let open_start = Instant::now();
let conn = open_db()?;
let open_elapsed = open_start.elapsed();
let query_start = Instant::now();
let result = conn
.query_row(
"SELECT field_json FROM stored_config_fields
conn.query_row(
"SELECT field_json FROM stored_config_fields
WHERE config_id = ?1 AND field_name = ?2",
params![config_id, field],
|row| row.get::<_, String>(0),
)
.ok();
ohrs_log_debug!(
"[Rust] get_config_field_value config={} field={} found={} open_ms={} query_ms={} total_ms={} len={}",
config_id,
field,
result.is_some(),
open_elapsed.as_millis(),
query_start.elapsed().as_millis(),
total_start.elapsed().as_millis(),
result.as_ref().map(|value| value.len()).unwrap_or(0)
);
result
params![config_id, field],
|row| row.get::<_, String>(0),
)
.ok()
}
pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
if field.contains('.') {
return false;
}
@@ -279,12 +191,15 @@ pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) ->
save_config_record(config_id.to_string(), display_name, normalized).is_some()
}
pub fn get_display_name(config_id: &str) -> Option<String> {
get_config_meta(config_id).map(|meta| meta.display_name)
}
pub fn get_default_config_json() -> Option<String> {
crate::build_default_network_config_json().ok()
}
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
validation::validate_config_id(&config_id).ok()?;
let raw = get_default_config_json()?;
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
config.instance_id = Some(config_id.clone());
@@ -293,21 +208,11 @@ pub fn create_config_record(config_id: String, display_name: String) -> Option<S
}
pub fn start_kernel_with_config_id(config_id: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
let started = crate::run_network_instance_from_json(&raw);
if started && let Ok(config) = serde_json::from_str::<NetworkConfig>(&raw) {
cache_runtime_config_snapshot(config_id.to_string(), display_name, config);
}
started
crate::run_network_instance_from_json(&raw)
}
pub fn list_config_meta_json() -> String {
@@ -315,9 +220,6 @@ pub fn list_config_meta_json() -> String {
}
pub fn delete_config_record(config_id: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
if let Some(path) = legacy_config_file_path(config_id) {
if path.exists() {
let _ = std::fs::remove_file(path);
@@ -332,24 +234,14 @@ pub fn delete_config_record(config_id: &str) -> bool {
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
ohrs_log_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
hilog_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
return false;
}
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
ohrs_log_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
delete_config_meta(config_id)
}
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
validation::validate_config_id(config_id).ok()?;
let record = get_config_record(config_id)?;
import_export::export_config_toml_from_record(&record)
}
@@ -1,4 +1,5 @@
use crate::config::storage::config_meta::{now_ts_string, open_db};
use ohos_hilog_binding::hilog_error;
use rusqlite::{Connection, params};
use serde_json::{Map, Value};
@@ -42,7 +43,7 @@ pub(super) fn replace_config_fields(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to clear existing config fields {}: {}",
config_id,
e
@@ -57,7 +58,7 @@ pub(super) fn replace_config_fields(
VALUES (?1, ?2, ?3, ?4)",
params![config_id, field_name, field_json, now_ts_string()],
) {
ohrs_log_error!("[Rust] failed to persist config field {}: {}", config_id, e);
hilog_error!("[Rust] failed to persist config field {}: {}", config_id, e);
return None;
}
}
@@ -1,17 +1,12 @@
use crate::config::storage::config_meta::get_config_meta;
use ohos_hilog_binding::hilog_error;
use std::path::PathBuf;
use super::validation;
pub(super) fn legacy_config_file_path(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
) -> Option<PathBuf> {
if !validation::is_valid_config_id(config_id) {
ohrs_log_error!("[Rust] invalid legacy config_id {}", config_id);
return None;
}
root_dir.as_ref().map(|root| {
root.join(config_dir_name)
.join(format!("{}.json", config_id))
@@ -40,7 +35,7 @@ pub(super) fn migrate_legacy_file_if_needed(
save_config_record(config_id.to_string(), display_name, raw)?;
if let Err(e) = std::fs::remove_file(&legacy_path) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to remove legacy config file {}: {}",
legacy_path.display(),
e
@@ -1,25 +1,13 @@
use easytier::proto::api::manage::NetworkConfig;
use serde_json::{Map, Value};
use uuid::Uuid;
pub(super) fn validate_config_id(config_id: &str) -> Result<(), String> {
if config_id.is_empty() {
return Err("config_id is required".to_string());
}
Uuid::parse_str(config_id)
.map(|_| ())
.map_err(|e| format!("invalid config_id {}: {}", config_id, e))
}
pub(super) fn is_valid_config_id(config_id: &str) -> bool {
validate_config_id(config_id).is_ok()
}
pub(super) fn normalize_config_id(
mut config: NetworkConfig,
requested_id: String,
) -> Result<NetworkConfig, String> {
validate_config_id(&requested_id)?;
if requested_id.is_empty() {
return Err("config_id is required".to_string());
}
config.instance_id = Some(requested_id);
Ok(config)
}
@@ -1,14 +1,9 @@
use crate::config;
use crate::config::types::stored_config::SnapshotImportResult;
pub(crate) fn init_config_store(root_dir: String) -> bool {
config::repository::init_config_store(root_dir)
}
pub(crate) fn reset_config_store() -> bool {
config::repository::reset_config_store()
}
pub(crate) fn list_configs() -> String {
config::repository::list_config_meta_json()
}
@@ -41,10 +36,6 @@ pub(crate) fn set_config_field(config_id: String, field: String, json_value: Str
config::repository::set_config_field_value(&config_id, &field, &json_value)
}
pub(crate) fn set_config_favorite(config_id: String, favorite: bool) -> bool {
config::storage::config_meta::set_config_favorite(config_id, favorite).is_some()
}
pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
config::repository::import_toml_config(toml_text, display_name)
.map(|record| record.meta.config_id)
@@ -61,9 +52,3 @@ pub(crate) fn export_config_store_snapshot(target_path: String) -> bool {
pub(crate) fn import_config_store_snapshot(source_path: String) -> bool {
config::storage::config_meta::import_config_store_snapshot(source_path)
}
pub(crate) fn import_config_store_snapshot_with_result(
source_path: String,
) -> SnapshotImportResult {
config::storage::config_meta::import_config_store_snapshot_with_result(source_path)
}
@@ -1,15 +1,18 @@
use crate::config::repository::{clear_runtime_config_snapshot, get_runtime_config_snapshot};
use crate::config::repository::load_config_json;
use crate::config::storage::config_meta::get_config_display_name;
use crate::config::types::stored_config::KeyValuePair;
use crate::kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use crate::runtime::state::runtime_state::{
RuntimeAggregateState, RuntimeInstanceState, TunAggregateState, clear_tun_attached,
is_tun_attached, mark_tun_attached, runtime_instance_from_config_snapshot,
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER, WEB_CLIENTS};
use crate::{ASYNC_RUNTIME, EASYTIER_VERSION, INSTANCE_MANAGER, WEB_CLIENTS};
use easytier::proto::api::manage::NetworkConfig;
use ohos_hilog_binding::{hilog_error, hilog_info};
use std::sync::Arc;
pub(crate) fn start_kernel(
config_id: String,
@@ -26,12 +29,9 @@ pub(crate) fn stop_kernel(
) -> bool {
clear_tun_attached(&config_id);
if stop_web_client(&config_id) {
clear_runtime_config_snapshot(&config_id);
return true;
}
let _ = stop_local_socket_server_inner();
let Some(instance_id) = parse_instance_uuid(&config_id) else {
return false;
};
@@ -40,20 +40,9 @@ pub(crate) fn stop_kernel(
.delete_network_instance(vec![instance_id])
.map(|_| true)
.unwrap_or_else(|err| {
ohrs_log_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
hilog_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
false
});
if ret {
clear_runtime_config_snapshot(&config_id);
}
let has_active_instances = !INSTANCE_MANAGER.list_network_instance_ids().is_empty();
let has_web_clients = WEB_CLIENTS
.lock()
.map(|guard| !guard.is_empty())
.unwrap_or(false);
if has_active_instances || has_web_clients {
let _ = start_local_socket_server_inner();
}
maybe_stop_local_socket_server();
ret
}
@@ -70,10 +59,10 @@ pub(crate) fn stop_network_instance(
}
pub(crate) fn collect_network_infos() -> Vec<KeyValuePair> {
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
ohrs_log_error!("[Rust] collect network infos failed {}", err);
hilog_error!("[Rust] collect network infos failed {}", err);
return vec![];
}
};
@@ -97,7 +86,7 @@ pub(crate) fn set_tun_fd(
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
) -> bool {
let Some(instance_id) = parse_instance_uuid(&config_id) else {
ohrs_log_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
hilog_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
return false;
};
@@ -105,7 +94,7 @@ pub(crate) fn set_tun_fd(
.set_tun_fd(&instance_id, fd)
.map(|_| {
mark_tun_attached(&config_id);
ohrs_log_info!(
hilog_info!(
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
config_id,
fd
@@ -113,16 +102,20 @@ pub(crate) fn set_tun_fd(
true
})
.unwrap_or_else(|err| {
ohrs_log_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
hilog_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
false
})
}
pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
pub(crate) fn get_runtime_snapshot() -> RuntimeAggregateState {
get_runtime_snapshot_inner()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
ohrs_log_error!("[Rust] collect network infos failed {}", err);
hilog_error!("[Rust] collect network infos failed {}", err);
return RuntimeAggregateState {
instances: vec![],
tun: TunAggregateState {
@@ -136,67 +129,30 @@ pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
};
}
};
let mut live_infos = infos
.into_iter()
.map(|(instance_id, info)| (instance_id.to_string(), info))
.collect::<std::collections::HashMap<_, _>>();
let mut active_config_ids = live_infos.keys().cloned().collect::<Vec<_>>();
if let Ok(guard) = WEB_CLIENTS.lock() {
for config_id in guard.keys() {
if !active_config_ids.iter().any(|value| value == config_id) {
active_config_ids.push(config_id.clone());
}
}
}
let mut instances = Vec::with_capacity(active_config_ids.len());
for config_id in active_config_ids {
if let Some(info) = live_infos.remove(&config_id) {
let snapshot = get_runtime_config_snapshot(&config_id);
let display_name = snapshot
.as_ref()
.map(|snapshot| snapshot.display_name.clone())
.unwrap_or_else(|| config_id.clone());
let magic_dns_enabled = snapshot
.as_ref()
.and_then(|snapshot| snapshot.config.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = snapshot
.as_ref()
.map(|snapshot| !snapshot.config.exit_nodes.is_empty())
.unwrap_or(false);
instances.push(runtime_instance_from_running_info(
config_id,
display_name,
magic_dns_enabled,
need_exit_node,
info,
));
} else if let Some(snapshot) = get_runtime_config_snapshot(&config_id) {
instances.push(runtime_instance_from_config_snapshot(
config_id,
snapshot.display_name,
snapshot.config,
true,
));
} else {
let tun_attached = is_tun_attached(&config_id);
instances.push(RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id.clone(),
display_name: config_id.clone(),
running: true,
tun_required: tun_attached,
tun_attached,
magic_dns_enabled: false,
need_exit_node: false,
error_message: None,
my_node_info: None,
events: Vec::new(),
routes: Vec::new(),
peers: Vec::new(),
});
}
let mut instances = Vec::with_capacity(infos.len());
for (instance_uuid, info) in infos {
let config_id = instance_uuid.to_string();
let display_name = get_config_display_name(&config_id).unwrap_or_else(|| config_id.clone());
let config_json = load_config_json(&config_id);
let stored_config = config_json
.as_deref()
.and_then(|raw| serde_json::from_str::<NetworkConfig>(raw).ok());
let magic_dns_enabled = stored_config
.as_ref()
.and_then(|cfg| cfg.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = stored_config
.as_ref()
.map(|cfg| !cfg.exit_nodes.is_empty())
.unwrap_or(false);
instances.push(runtime_instance_from_running_info(
config_id,
display_name,
magic_dns_enabled,
need_exit_node,
info,
));
}
instances.sort_by(|a, b| {
@@ -32,13 +32,6 @@ pub(crate) fn send_local_socket_message(
Ok(())
}
fn shrink_clients_if_sparse(clients: &mut Vec<UnixStream>) {
let sparse_limit = clients.len().saturating_mul(2).max(4);
if clients.capacity() > sparse_limit {
clients.shrink_to_fit();
}
}
pub(crate) fn broadcast_local_socket_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
@@ -52,42 +45,6 @@ pub(crate) fn broadcast_local_socket_message(
active_clients.push(client);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
pub(crate) fn send_local_socket_json_payload_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: &str,
) -> std::io::Result<()> {
let message_type_json = serde_json::to_string(message_type)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
let mut raw = Vec::with_capacity(message_type_json.len() + payload_json.len() + 38);
raw.extend_from_slice(b"{\"messageType\":");
raw.extend_from_slice(message_type_json.as_bytes());
raw.extend_from_slice(b",\"payloadJson\":");
raw.extend_from_slice(payload_json.as_bytes());
raw.extend_from_slice(b"}\n");
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_json_payload_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
payload_json: &str,
) -> bool {
let mut active_clients = Vec::with_capacity(clients.len());
let mut delivered = false;
for mut client in clients.drain(..) {
if send_local_socket_json_payload_message(&mut client, message_type, payload_json).is_ok() {
delivered = true;
active_clients.push(client);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
@@ -1,12 +1,20 @@
use crate::config::repository::get_runtime_config_route_overrides;
use crate::config::repository::load_config_json;
use crate::runtime::state::runtime_state::RuntimeInstanceState;
use easytier::proto::api::manage::NetworkConfig;
use ipnet::IpNet;
use ohos_hilog_binding::hilog_debug;
use std::collections::HashSet;
use std::net::IpAddr;
pub(crate) fn load_manual_routes(config_id: &str) -> Vec<String> {
load_config_json(config_id)
.and_then(|raw| serde_json::from_str::<NetworkConfig>(&raw).ok())
.map(|config| config.routes)
.unwrap_or_default()
}
fn normalize_route_cidr(route: &str) -> Option<String> {
let normalized = route.split("->").next().unwrap_or(route).trim();
normalized
route
.parse::<IpNet>()
.ok()
.map(|network| match network {
@@ -14,7 +22,7 @@ fn normalize_route_cidr(route: &str) -> Option<String> {
IpNet::V6(net) => net.trunc().to_string(),
})
.or_else(|| {
normalized.parse::<IpAddr>().ok().map(|addr| match addr {
route.parse::<IpAddr>().ok().map(|addr| match addr {
IpAddr::V4(ip) => format!("{}/32", ip),
IpAddr::V6(ip) => format!("{}/128", ip),
})
@@ -59,9 +67,8 @@ pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<Strin
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
let (manual_routes, config_proxy_cidrs) =
get_runtime_config_route_overrides(&instance.config_id);
let runtime_proxy_cidrs = instance
let manual_routes = load_manual_routes(&instance.config_id);
let proxy_cidrs = instance
.routes
.iter()
.flat_map(|route| route.proxy_cidrs.iter().cloned())
@@ -73,9 +80,15 @@ pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<Strin
}
raw_routes.extend(manual_routes.iter().cloned());
raw_routes.extend(config_proxy_cidrs.iter().cloned());
raw_routes.extend(runtime_proxy_cidrs.iter().cloned());
simplify_routes(raw_routes)
raw_routes.extend(proxy_cidrs.iter().cloned());
let aggregated_routes = simplify_routes(raw_routes);
hilog_debug!(
"[Rust] aggregate_tun_routes instance={} proxy_cidrs={:?} aggregated_routes={:?}",
instance.instance_id,
proxy_cidrs,
aggregated_routes
);
aggregated_routes
}
pub(crate) fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec<String> {
@@ -1,27 +1,17 @@
use super::protocol::{
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
};
use crate::collect_runtime_state_inner;
use super::protocol::{TunRequestPayload, broadcast_local_socket_message};
use crate::config::repository::kernel_socket_path;
use crate::get_runtime_snapshot_inner;
use crate::kernel_bridge::routing::aggregate_tun_routes;
use crate::runtime::state::runtime_state::{
PeerConnInfo as RuntimePeerConnInfo, RuntimeAggregateState, peer_conn_to_view,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER};
use easytier::common::global_ctx::{EventBusSubscriber, GlobalCtxEvent};
use easytier::proto::api::instance::ListPeerRequest;
use easytier::proto::rpc_types::controller::BaseController;
use ohos_hilog_binding::{hilog_error, hilog_info};
use once_cell::sync::Lazy;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::io::ErrorKind;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use std::time::Duration;
struct LocalSocketState {
stop_flag: std::sync::Arc<AtomicBool>,
@@ -30,287 +20,12 @@ struct LocalSocketState {
}
static LOCAL_SOCKET_STATE: Lazy<Mutex<Option<LocalSocketState>>> = Lazy::new(|| Mutex::new(None));
const SOCKET_TICK_INTERVAL: Duration = Duration::from_millis(250);
const TRAFFIC_STATS_INTERVAL: Duration = Duration::from_secs(1);
const INSTANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
const TUN_FAST_CHECK_WINDOW: Duration = Duration::from_secs(8);
const EVENT_RECEIVER_SYNC_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct TrafficStatsPayload {
instances: Vec<InstanceTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct InstanceTrafficStats {
config_id: String,
instance_id: String,
rx_bytes: i64,
tx_bytes: i64,
peers: Vec<PeerTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PeerTrafficStats {
peer_id: i64,
rx_bytes: i64,
tx_bytes: i64,
total_bytes: i64,
latency_us: i64,
loss_rate: f64,
}
struct PendingPeerEvent {
event: &'static str,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
#[derive(Default)]
struct DrainedKernelEvents {
tun_refresh: bool,
topology_lost: bool,
peer_events: Vec<PendingPeerEvent>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RuntimePeerEventPayload {
event: &'static str,
config_id: String,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
fn shrink_hash_map_if_sparse<K: Eq + Hash, V>(map: &mut HashMap<K, V>) {
let sparse_limit = map.len().saturating_mul(2).max(8);
if map.capacity() > sparse_limit {
map.shrink_to_fit();
}
}
fn shrink_hash_set_if_sparse<T: Eq + Hash>(set: &mut HashSet<T>) {
let sparse_limit = set.len().saturating_mul(2).max(8);
if set.capacity() > sparse_limit {
set.shrink_to_fit();
}
}
fn sync_tun_event_receivers(receivers: &mut HashMap<String, EventBusSubscriber>) {
let mut active_instance_ids = HashSet::new();
for instance in INSTANCE_MANAGER.iter() {
let instance_id = instance.key().to_string();
active_instance_ids.insert(instance_id.clone());
if !receivers.contains_key(&instance_id)
&& let Some(receiver) = instance.value().subscribe_event()
{
receivers.insert(instance_id, receiver);
}
}
receivers.retain(|instance_id, _| active_instance_ids.contains(instance_id));
shrink_hash_map_if_sparse(receivers);
}
fn event_needs_tun_refresh(event: &GlobalCtxEvent) -> bool {
matches!(
event,
GlobalCtxEvent::DhcpIpv4Changed(_, _)
| GlobalCtxEvent::ProxyCidrsUpdated(_, _)
| GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)
)
}
fn drain_kernel_events(receivers: &mut HashMap<String, EventBusSubscriber>) -> DrainedKernelEvents {
let mut drained = DrainedKernelEvents::default();
let mut closed_receivers = Vec::new();
for (instance_id, receiver) in receivers.iter_mut() {
loop {
match receiver.try_recv() {
Ok(event) => {
drained.tun_refresh = event_needs_tun_refresh(&event) || drained.tun_refresh;
match event {
GlobalCtxEvent::PeerAdded(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_added",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerRemoved(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_removed",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerConnAdded(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_added",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
GlobalCtxEvent::PeerConnRemoved(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_removed",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
_ => {}
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
drained.topology_lost = true;
continue;
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
closed_receivers.push(instance_id.clone());
break;
}
}
}
}
for instance_id in closed_receivers {
receivers.remove(&instance_id);
}
drained
}
fn broadcast_runtime_peer_events(
clients: &mut Vec<UnixStream>,
peer_events: Vec<PendingPeerEvent>,
) {
for event in peer_events {
let payload = RuntimePeerEventPayload {
event: event.event,
config_id: event.instance_id.clone(),
instance_id: event.instance_id,
peer_id: event.peer_id,
conn: event.conn,
};
match serde_json::to_string(&payload) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
clients,
"runtime_peer_event",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime peer event failed: {}", err);
}
}
}
}
fn tun_candidate_ids(snapshot: &RuntimeAggregateState) -> HashSet<String> {
snapshot
.instances
.iter()
.filter(|instance| instance.running && instance.tun_required)
.map(|instance| instance.instance_id.clone())
.collect()
}
fn collect_traffic_stats() -> TrafficStatsPayload {
let services = INSTANCE_MANAGER
.iter()
.filter_map(|instance| {
instance
.value()
.get_api_service()
.map(|api_service| (instance.key().to_string(), api_service))
})
.collect::<Vec<_>>();
let instances = ASYNC_RUNTIME.block_on(async {
let mut instances = Vec::new();
for (instance_id, api_service) in services {
let peers = match api_service
.get_peer_manage_service()
.list_peer(BaseController::default(), ListPeerRequest::default())
.await
{
Ok(response) => response.peer_infos,
Err(err) => {
ohrs_log_debug!(
"[Rust] collect traffic stats list_peer failed instance={}: {}",
instance_id,
err
);
continue;
}
};
let mut instance_rx_bytes = 0i64;
let mut instance_tx_bytes = 0i64;
let mut peer_stats = Vec::with_capacity(peers.len());
for peer in peers {
let mut peer_rx_bytes = 0i64;
let mut peer_tx_bytes = 0i64;
let mut latency_us = i64::MAX;
let mut loss_rate = 0f64;
for conn in peer.conns {
if let Some(stats) = conn.stats {
let rx_bytes = stats.rx_bytes as i64;
let tx_bytes = stats.tx_bytes as i64;
peer_rx_bytes += rx_bytes;
peer_tx_bytes += tx_bytes;
latency_us = latency_us.min(stats.latency_us as i64);
}
loss_rate = loss_rate.max(conn.loss_rate as f64);
}
instance_rx_bytes += peer_rx_bytes;
instance_tx_bytes += peer_tx_bytes;
peer_stats.push(PeerTrafficStats {
peer_id: peer.peer_id as i64,
rx_bytes: peer_rx_bytes,
tx_bytes: peer_tx_bytes,
total_bytes: peer_rx_bytes + peer_tx_bytes,
latency_us: if latency_us == i64::MAX {
-1
} else {
latency_us
},
loss_rate,
});
}
instances.push(InstanceTrafficStats {
config_id: instance_id.clone(),
instance_id,
rx_bytes: instance_rx_bytes,
tx_bytes: instance_tx_bytes,
peers: peer_stats,
});
}
instances
});
TrafficStatsPayload { instances }
}
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
ohrs_log_error!("[Rust] kernel socket path unavailable");
hilog_error!("[Rust] kernel socket path unavailable");
return false;
}
};
@@ -319,7 +34,7 @@ pub fn start_local_socket_server() -> bool {
Ok(guard) if guard.is_some() => return true,
Ok(_) => {}
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
}
@@ -331,7 +46,7 @@ pub fn start_local_socket_server() -> bool {
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) => {
ohrs_log_error!(
hilog_error!(
"[Rust] bind localsocket failed {}: {}",
socket_path.display(),
err
@@ -340,7 +55,7 @@ pub fn start_local_socket_server() -> bool {
}
};
if let Err(err) = listener.set_nonblocking(true) {
ohrs_log_error!("[Rust] set localsocket nonblocking failed: {}", err);
hilog_error!("[Rust] set localsocket nonblocking failed: {}", err);
let _ = std::fs::remove_file(&socket_path);
return false;
}
@@ -348,208 +63,102 @@ pub fn start_local_socket_server() -> bool {
let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
let worker_stop_flag = stop_flag.clone();
let worker = thread::spawn(move || {
let mut last_topology_json = String::new();
let mut last_snapshot_json = String::new();
let mut delivered_tun_requests = HashSet::new();
let mut last_tun_route_signatures = HashMap::<String, String>::new();
let mut tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
let mut tun_bootstrap_done = false;
let mut last_event_receiver_sync_at: Option<Instant> = None;
let mut last_traffic_stats_at: Option<Instant> = None;
let mut last_instance_poll_at: Option<Instant> = None;
let mut tun_event_receivers = HashMap::<String, EventBusSubscriber>::new();
let mut clients = Vec::<UnixStream>::new();
while !worker_stop_flag.load(Ordering::Relaxed) {
let mut full_topology_dirty = false;
let mut accepted_client = false;
loop {
match listener.accept() {
Ok((stream, _addr)) => {
accepted_client = true;
full_topology_dirty = true;
clients.push(stream);
tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
tun_bootstrap_done = false;
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
ohrs_log_error!("[Rust] accept localsocket failed: {}", err);
hilog_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
if clients.is_empty() {
if !last_topology_json.is_empty() {
last_topology_json.clear();
last_topology_json.shrink_to_fit();
}
delivered_tun_requests.clear();
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
last_tun_route_signatures.clear();
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
tun_event_receivers.clear();
shrink_hash_map_if_sparse(&mut tun_event_receivers);
clients.shrink_to_fit();
last_event_receiver_sync_at = None;
last_traffic_stats_at = None;
last_instance_poll_at = None;
tun_bootstrap_done = false;
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let now = Instant::now();
let should_sync_event_receivers = accepted_client
|| last_event_receiver_sync_at
.map(|last| now.duration_since(last) >= EVENT_RECEIVER_SYNC_INTERVAL)
.unwrap_or(true);
if should_sync_event_receivers {
sync_tun_event_receivers(&mut tun_event_receivers);
last_event_receiver_sync_at = Some(now);
}
let drained_events = drain_kernel_events(&mut tun_event_receivers);
let tun_refresh = drained_events.tun_refresh;
let topology_lost = drained_events.topology_lost;
let peer_events = drained_events.peer_events;
if topology_lost {
full_topology_dirty = true;
}
if tun_refresh {
tun_bootstrap_done = false;
tun_fast_until = now + TUN_FAST_CHECK_WINDOW;
}
if !peer_events.is_empty() {
broadcast_runtime_peer_events(&mut clients, peer_events);
}
let should_collect_traffic_stats = last_traffic_stats_at
.map(|last| now.duration_since(last) >= TRAFFIC_STATS_INTERVAL)
.unwrap_or(true);
if should_collect_traffic_stats {
last_traffic_stats_at = Some(now);
match serde_json::to_string(&collect_traffic_stats()) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"traffic_stats",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize traffic stats failed: {}", err);
}
}
}
let should_poll_instance = last_instance_poll_at
.map(|last| now.duration_since(last) >= INSTANCE_POLL_INTERVAL)
.unwrap_or(true);
let should_collect_topology = accepted_client
|| full_topology_dirty
|| tun_refresh
|| should_poll_instance
|| (!tun_bootstrap_done && now < tun_fast_until);
if !should_collect_topology {
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let snapshot = collect_runtime_state_inner();
last_instance_poll_at = Some(now);
match serde_json::to_string(&snapshot) {
Ok(json) => {
if accepted_client || full_topology_dirty || json != last_topology_json {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"runtime_topology",
&json,
);
last_topology_json = json;
}
}
let snapshot = get_runtime_snapshot_inner();
let snapshot_json = match serde_json::to_string(&snapshot) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime topology failed: {}", err);
hilog_error!("[Rust] serialize runtime snapshot failed: {}", err);
thread::sleep(Duration::from_millis(250));
continue;
}
};
if accepted_client || snapshot_json != last_snapshot_json {
let _ = broadcast_local_socket_message(
&mut clients,
"runtime_snapshot",
&snapshot_json,
);
last_snapshot_json = snapshot_json;
}
let active_tun_candidate_ids = tun_candidate_ids(&snapshot);
delivered_tun_requests
.retain(|instance_id| active_tun_candidate_ids.contains(instance_id));
last_tun_route_signatures
.retain(|instance_id, _| active_tun_candidate_ids.contains(instance_id));
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
let mut saw_running_instance = false;
let mut saw_tun_candidate = false;
for instance in snapshot.instances.iter() {
if instance.running {
saw_running_instance = true;
}
if !(instance.running && instance.tun_required) {
continue;
}
saw_tun_candidate = true;
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&(
&virtual_ipv4,
&virtual_ipv4_cidr,
&aggregated_routes,
instance.magic_dns_enabled,
instance.need_exit_node,
))
.unwrap_or_else(|_| "[]".to_string());
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize tun request failed: {}", err);
if instance.running && instance.tun_required {
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures.insert(instance.instance_id.clone(), route_signature);
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&aggregated_routes)
.unwrap_or_else(|_| "[]".to_string());
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
hilog_error!("[Rust] serialize tun request failed: {}", err);
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures
.insert(instance.instance_id.clone(), route_signature);
}
} else {
delivered_tun_requests.remove(&instance.instance_id);
last_tun_route_signatures.remove(&instance.instance_id);
}
}
if !delivered_tun_requests.is_empty()
|| (saw_running_instance && !saw_tun_candidate)
|| now >= tun_fast_until
{
tun_bootstrap_done = true;
}
thread::sleep(SOCKET_TICK_INTERVAL);
thread::sleep(Duration::from_millis(250));
}
});
@@ -563,7 +172,7 @@ pub fn start_local_socket_server() -> bool {
true
}
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
false
}
}
@@ -573,7 +182,7 @@ pub fn stop_local_socket_server() -> bool {
let state = match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => guard.take(),
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
};
+34 -79
View File
@@ -1,46 +1,14 @@
macro_rules! ohrs_log_error {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(5) {
$crate::platform::logging::log_manager::record_app_log(
5,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
macro_rules! ohrs_log_info {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(4) {
$crate::platform::logging::log_manager::record_app_log(
4,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
macro_rules! ohrs_log_debug {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(3) {
$crate::platform::logging::log_manager::record_app_log(
3,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
mod config;
mod exports;
mod kernel_bridge;
mod platform;
mod runtime;
use config::repository::{cache_runtime_config_snapshot, start_kernel_with_config_id};
use config::repository::{
create_config_record, delete_config_record, export_config_toml, get_config_field_value,
get_default_config_json, import_toml_config, init_config_store as init_repo_store,
list_config_meta_json, save_config_record, set_config_field_value, start_kernel_with_config_id,
};
use config::services::schema_service::{
ConfigFieldMapping, NetworkConfigSchema,
get_network_config_field_mappings as build_network_config_field_mappings,
@@ -52,7 +20,7 @@ use config::services::share_link_service::{
parse_config_share_link as parse_config_share_link_inner,
};
use config::storage::config_meta::get_config_display_name;
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload, SnapshotImportResult};
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload};
use easytier::common::constants::EASYTIER_VERSION;
use easytier::common::{
MachineIdOptions,
@@ -63,11 +31,15 @@ use easytier::proto::api::manage::NetworkConfig;
use easytier::proto::api::manage::NetworkingMethod;
use easytier::web_client::{WebClient, WebClientHooks, run_web_client};
use kernel_bridge::{
start_local_socket_server as start_local_socket_server_inner,
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use napi_derive_ohos::napi;
use runtime::state::runtime_state::RuntimeAggregateState;
use ohos_hilog_binding::{hilog_error, hilog_info};
use runtime::state::runtime_state::{
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use std::collections::{HashMap, HashSet};
use std::format;
use std::sync::{Arc, Mutex};
@@ -129,7 +101,7 @@ fn stop_web_client(config_id: &str) -> bool {
let managed = match WEB_CLIENTS.lock() {
Ok(mut guard) => guard.remove(config_id),
Err(err) => {
ohrs_log_error!("[Rust] stop_web_client lock failed {}", err);
hilog_error!("[Rust] stop_web_client lock failed {}", err);
return false;
}
};
@@ -155,7 +127,7 @@ fn stop_web_client(config_id: &str) -> bool {
.delete_network_instance(tracked_ids)
.map(|_| true)
.unwrap_or_else(|err| {
ohrs_log_error!(
hilog_error!(
"[Rust] stop config server instances failed {}: {}",
config_id,
err
@@ -188,12 +160,12 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
.next()
.is_some()
{
ohrs_log_error!("[Rust] there is a running instance!");
hilog_error!("[Rust] there is a running instance!");
return false;
}
let Some(config_server_url) = config.public_server_url.clone() else {
ohrs_log_error!("[Rust] public_server_url missing for config server mode");
hilog_error!("[Rust] public_server_url missing for config server mode");
return false;
};
let hooks = Arc::new(TrackedWebClientHooks::default());
@@ -220,7 +192,7 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
let client = match client {
Ok(client) => client,
Err(err) => {
ohrs_log_error!("[Rust] start config server failed {}", err);
hilog_error!("[Rust] start config server failed {}", err);
return false;
}
};
@@ -237,7 +209,7 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
true
}
Err(err) => {
ohrs_log_error!("[Rust] store config server client failed {}", err);
hilog_error!("[Rust] store config server client failed {}", err);
false
}
}
@@ -268,33 +240,29 @@ pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
let config = match serde_json::from_str::<NetworkConfig>(cfg_json) {
Ok(cfg) => cfg,
Err(e) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if is_config_server_config(&config) {
let Some(config_id) = config.instance_id.as_deref() else {
ohrs_log_error!("[Rust] config server config missing instance id");
hilog_error!("[Rust] config server config missing instance id");
return false;
};
let started = run_config_server_instance(config_id, &config);
if started {
cache_runtime_config_snapshot(config_id.to_string(), config_id.to_string(), config);
}
return started;
return run_config_server_instance(config_id, &config);
}
let cfg = match config.gen_config() {
Ok(toml) => toml,
Err(e) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() {
ohrs_log_error!("[Rust] there is a running instance!");
hilog_error!("[Rust] there is a running instance!");
return false;
}
@@ -307,17 +275,14 @@ pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
.list_network_instance_ids()
.contains(&inst_id)
{
ohrs_log_error!("[Rust] instance {} already exists", inst_id);
hilog_error!("[Rust] instance {} already exists", inst_id);
return false;
}
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(_) => {
cache_runtime_config_snapshot(inst_id.to_string(), inst_id.to_string(), config);
true
}
Ok(_) => true,
Err(err) => {
ohrs_log_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
hilog_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
false
}
}
@@ -327,7 +292,7 @@ fn parse_instance_uuid(config_id: &str) -> Option<Uuid> {
match Uuid::parse_str(config_id) {
Ok(uuid) => Some(uuid),
Err(err) => {
ohrs_log_error!("[Rust] invalid config_id {}: {}", config_id, err);
hilog_error!("[Rust] invalid config_id {}: {}", config_id, err);
None
}
}
@@ -338,11 +303,6 @@ pub fn init_config_store(root_dir: String) -> bool {
exports::config_api::init_config_store(root_dir)
}
#[napi]
pub fn reset_config_store() -> bool {
exports::config_api::reset_config_store()
}
#[napi]
pub fn list_configs() -> String {
exports::config_api::list_configs()
@@ -393,11 +353,6 @@ pub fn set_config_field(config_id: String, field: String, json_value: String) ->
exports::config_api::set_config_field(config_id, field, json_value)
}
#[napi]
pub fn set_config_favorite(config_id: String, favorite: bool) -> bool {
exports::config_api::set_config_favorite(config_id, favorite)
}
#[napi]
pub fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
exports::config_api::import_toml(toml_text, display_name)
@@ -418,11 +373,6 @@ pub fn import_config_store_snapshot(source_path: String) -> bool {
exports::config_api::import_config_store_snapshot(source_path)
}
#[napi]
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
exports::config_api::import_config_store_snapshot_with_result(source_path)
}
#[napi]
pub fn start_kernel(config_id: String) -> bool {
exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)
@@ -517,8 +467,13 @@ mod tests {
}
}
pub(crate) fn collect_runtime_state_inner() -> RuntimeAggregateState {
exports::runtime_api::collect_runtime_state()
#[napi]
pub fn get_runtime_snapshot() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot_inner()
}
#[napi]
@@ -1,393 +0,0 @@
use napi_derive_ohos::napi;
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::fs::{self, Metadata, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const LOG_DIR_NAME: &str = "easytier-logs";
const LOG_FILE_PREFIX: &str = "easytier-";
const LOG_FILE_SUFFIX: &str = ".log";
const MAX_LOG_FILES: usize = 10;
const MAX_MEMORY_LINES: usize = 500;
#[derive(Debug, Clone)]
#[napi(object)]
pub struct LogFileInfo {
pub file_name: String,
pub display_name: String,
pub size_bytes: i64,
pub modified_ms: i64,
pub active: bool,
}
#[derive(Clone)]
struct LogOptions {
core_log: bool,
debug_log: bool,
}
impl Default for LogOptions {
fn default() -> Self {
Self {
core_log: false,
debug_log: false,
}
}
}
#[derive(Default)]
struct LogManagerState {
log_dir: Option<PathBuf>,
active_file: Option<PathBuf>,
lines: VecDeque<String>,
options: LogOptions,
}
static LOG_MANAGER: Lazy<Mutex<LogManagerState>> =
Lazy::new(|| Mutex::new(LogManagerState::default()));
static CORE_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
static DEBUG_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
fn sanitize_name(raw: &str) -> String {
let value = raw
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
if value.is_empty() {
"process".to_string()
} else {
value
}
}
fn log_dir(root_dir: &str) -> PathBuf {
Path::new(root_dir).join(LOG_DIR_NAME)
}
fn is_log_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(LOG_FILE_PREFIX) && name.ends_with(LOG_FILE_SUFFIX))
.unwrap_or(false)
}
fn sorted_log_files(dir: &Path) -> Vec<PathBuf> {
let mut files = fs::read_dir(dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| entry.path())
.filter(|path| is_log_file(path))
.collect::<Vec<_>>();
files.sort_by(|left, right| {
left.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.cmp(
right
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default(),
)
});
files
}
fn current_log_state() -> Option<(PathBuf, Option<PathBuf>)> {
LOG_MANAGER.lock().ok().and_then(|guard| {
guard
.log_dir
.clone()
.map(|dir| (dir, guard.active_file.clone()))
})
}
fn file_name(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.to_string())
}
fn latest_process_log_file(dir: &Path, process_name: &str) -> Option<PathBuf> {
let suffix = format!("-{}{}", sanitize_name(process_name), LOG_FILE_SUFFIX);
sorted_log_files(dir).into_iter().rev().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.ends_with(&suffix))
.unwrap_or(false)
})
}
fn modified_millis(metadata: &Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
fn resolve_log_file(dir: &Path, requested_name: &str) -> Option<PathBuf> {
if requested_name.contains('/')
|| requested_name.contains('\\')
|| requested_name.contains("..")
{
return None;
}
sorted_log_files(dir).into_iter().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value == requested_name)
.unwrap_or(false)
})
}
fn cleanup_old_logs(dir: &Path) {
let files = sorted_log_files(dir);
let overflow = files.len().saturating_sub(MAX_LOG_FILES);
for path in files.into_iter().take(overflow) {
let _ = fs::remove_file(path);
}
}
fn push_memory_line(state: &mut LogManagerState, line: String) {
state.lines.push_back(line);
while state.lines.len() > MAX_MEMORY_LINES {
state.lines.pop_front();
}
}
fn append_log_file(path: &Path, line: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{}", line);
}
}
fn should_record_debug(level: i32) -> bool {
level <= 3
}
fn format_line(level: i32, target: &str, message: &str) -> String {
format!("{}[{}] {}", level, target, message.replace('\n', "\\n"))
}
pub(crate) fn configure(core_log: bool, debug_log: bool) {
CORE_LOG_ENABLED.store(core_log, Ordering::Relaxed);
DEBUG_LOG_ENABLED.store(debug_log, Ordering::Relaxed);
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.options.core_log = core_log;
guard.options.debug_log = debug_log;
}
}
pub(crate) fn app_log_enabled(level: i32) -> bool {
!should_record_debug(level) || DEBUG_LOG_ENABLED.load(Ordering::Relaxed)
}
pub(crate) fn core_log_enabled(level: i32) -> bool {
CORE_LOG_ENABLED.load(Ordering::Relaxed) && app_log_enabled(level)
}
pub(crate) fn record_app_log(level: i32, target: &str, message: &str) {
if !app_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
pub(crate) fn record_core_log(level: i32, target: &str, message: &str) {
if !core_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
#[napi]
pub fn init_log_manager(root_dir: String, process_name: String) -> bool {
let dir = log_dir(&root_dir);
if fs::create_dir_all(&dir).is_err() {
return false;
}
if LOG_MANAGER
.lock()
.map(|guard| guard.active_file.is_some())
.unwrap_or(false)
{
cleanup_old_logs(&dir);
return true;
}
let sanitized_process_name = sanitize_name(&process_name);
let active_file = if sanitized_process_name == "ui" {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
} else if let Some(path) = latest_process_log_file(&dir, "ui") {
path
} else {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
};
if OpenOptions::new()
.create(true)
.append(true)
.open(&active_file)
.is_err()
{
return false;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.log_dir = Some(dir.clone());
guard.active_file = Some(active_file);
guard.lines.clear();
}
cleanup_old_logs(&dir);
true
}
#[napi]
pub fn configure_log_manager(core_log: bool, debug_log: bool) {
configure(core_log, debug_log);
}
#[napi]
pub fn write_app_log(level: i32, target: String, message: String) {
record_app_log(level, &target, &message);
}
#[napi]
pub fn drain_log_lines() -> Vec<String> {
LOG_MANAGER
.lock()
.map(|mut guard| guard.lines.drain(..).collect())
.unwrap_or_default()
}
#[napi]
pub fn list_log_files() -> Vec<LogFileInfo> {
let Some((log_dir, active_file)) = current_log_state() else {
return Vec::new();
};
let active_name = active_file.as_ref().and_then(|path| file_name(path));
let mut files = sorted_log_files(&log_dir);
files.reverse();
files
.into_iter()
.filter_map(|path| {
let file_name = file_name(&path)?;
let active = active_name
.as_ref()
.map(|name| name == &file_name)
.unwrap_or(false);
let metadata = fs::metadata(&path).ok();
Some(LogFileInfo {
file_name,
display_name: if active {
"当前启动日志".to_string()
} else {
"历史日志".to_string()
},
size_bytes: metadata
.as_ref()
.map(|value| value.len().min(i64::MAX as u64) as i64)
.unwrap_or(0),
modified_ms: metadata.as_ref().map(modified_millis).unwrap_or_default(),
active,
})
})
.collect()
}
#[napi]
pub fn read_log_file(file_name: String) -> Option<String> {
let (log_dir, _) = current_log_state()?;
let path = resolve_log_file(&log_dir, &file_name)?;
fs::read_to_string(path).ok()
}
#[napi]
pub fn export_log_file(file_name: String, target_path: String) -> bool {
let Some((log_dir, _)) = current_log_state() else {
return false;
};
let Some(path) = resolve_log_file(&log_dir, &file_name) else {
return false;
};
fs::copy(path, target_path).is_ok()
}
#[napi]
pub fn export_log_archive(target_path: String) -> bool {
let log_dir = LOG_MANAGER
.lock()
.ok()
.and_then(|guard| guard.log_dir.clone());
let Some(log_dir) = log_dir else {
return false;
};
let files = sorted_log_files(&log_dir);
let mut output = match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&target_path)
{
Ok(file) => file,
Err(_) => return false,
};
for path in files {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("unknown.log");
let _ = writeln!(output, "===== {} =====", name);
if let Ok(content) = fs::read_to_string(&path) {
let _ = writeln!(output, "{}", content);
}
}
true
}
@@ -1,2 +1 @@
pub(crate) mod log_manager;
pub(crate) mod native_log;
@@ -1,5 +1,7 @@
use super::log_manager;
use napi_derive_ohos::napi;
use ohos_hilog_binding::{
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
};
use std::collections::HashMap;
use std::panic;
use tracing::{Event, Subscriber};
@@ -8,9 +10,8 @@ use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
static INITIALIZED: std::sync::Once = std::sync::Once::new();
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
fn panic_hook(info: &panic::PanicHookInfo) {
log_manager::record_core_log(5, "RustPanic", &format!("{}", info));
hilog_error!("RUST PANIC: {}", info);
}
#[napi]
@@ -22,40 +23,45 @@ pub fn init_panic_hook() {
#[napi]
pub fn hilog_global_options(domain: u32, tag: String) {
let _ = domain;
let _ = tag;
ohos_hilog_binding::forward_stdio_to_hilog();
set_global_options(LogOptions {
domain,
tag: Box::leak(tag.clone().into_boxed_str()),
})
}
#[napi]
pub fn init_tracing_subscriber() {
TRACING_INITIALIZED.call_once(|| {
let _ = tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.try_init();
});
tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.init();
}
fn tracing_callback(event: &Event, fields: HashMap<String, String>) {
let metadata = event.metadata();
let loc = metadata
.target()
.split("::")
.last()
.unwrap_or(metadata.target());
let level = match *metadata.level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
#[cfg(target_env = "ohos")]
{
let loc = metadata.target().split("::").last().unwrap();
match *metadata.level() {
Level::TRACE => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::DEBUG => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::INFO => {
hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::WARN => {
hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::ERROR => {
hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
}
}
let values = fields.values().cloned().collect::<Vec<_>>().join(" ");
log_manager::record_core_log(level, &format!("Rust:{}", loc), &values);
}
struct CallbackLayer {
@@ -64,16 +70,6 @@ struct CallbackLayer {
impl<S: Subscriber> Layer<S> for CallbackLayer {
fn on_event(&self, event: &Event, _ctx: Context<S>) {
let level = match *event.metadata().level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
}
// 使用 fmt::format::FmtSpan 提取字段值
let mut fields = HashMap::new();
let mut visitor = FieldCollector(&mut fields);
@@ -3,7 +3,6 @@ use napi_derive_ohos::napi;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Mutex;
use url::Url;
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
@@ -159,136 +158,6 @@ fn stringify_uuid(value: Option<common::Uuid>) -> Option<String> {
value.map(|v| v.to_string())
}
fn non_empty_string(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn config_virtual_ipv4_cidr(config: &api::manage::NetworkConfig) -> Option<String> {
non_empty_string(config.virtual_ipv4.clone())
.map(|ipv4| format!("{}/{}", ipv4, config.network_length.unwrap_or(24)))
}
fn config_endpoint_urls(config: &api::manage::NetworkConfig) -> Vec<String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
if let Some(url) = non_empty_string(config.public_server_url.clone())
&& seen.insert(url.clone())
{
urls.push(url);
}
for raw in &config.peer_urls {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let value = trimmed.to_string();
if seen.insert(value.clone()) {
urls.push(value);
}
}
urls
}
fn endpoint_url(url: &str) -> Option<Url> {
Url::parse(url).ok()
}
fn endpoint_scheme(url: &str) -> Option<String> {
endpoint_url(url)
.map(|parsed| parsed.scheme().to_string())
.or_else(|| {
let scheme = url.split("://").next().unwrap_or("").trim();
(!scheme.is_empty()).then_some(scheme.to_string())
})
}
fn endpoint_label(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return format!("[Config] {}", host);
}
format!("[Config] {}", url)
}
fn endpoint_remote_display(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return parsed
.port()
.map(|port| format!("{}:{}", host, port))
.unwrap_or_else(|| host.to_string());
}
url.to_string()
}
fn configured_peer_id(index: usize) -> i64 {
9_000_000 + index as i64
}
fn configured_route_views(endpoints: &[String], public_server_url: Option<&str>) -> Vec<RouteView> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| RouteView {
peer_id: configured_peer_id(index),
hostname: Some(endpoint_label(endpoint)),
ipv4: Some(endpoint_remote_display(endpoint)),
ipv4_cidr: None,
ipv6_cidr: None,
proxy_cidrs: Vec::new(),
next_hop_peer_id: None,
cost: Some(0),
path_latency: None,
udp_nat_type: None,
tcp_nat_type: None,
inst_id: None,
version: None,
is_public_server: public_server_url.map(|url| url == endpoint),
})
.collect()
}
fn configured_peer_views(endpoints: &[String]) -> Vec<PeerInfo> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| {
let conn_id = format!("configured-peer-{}", index);
PeerInfo {
peer_id: configured_peer_id(index),
default_conn_id: Some(conn_id.clone()),
directly_connected_conns: vec![conn_id.clone()],
conns: vec![PeerConnInfo {
conn_id,
my_peer_id: 0,
peer_id: configured_peer_id(index),
features: Vec::new(),
tunnel_type: endpoint_scheme(endpoint),
local_addr: None,
remote_addr: Some(endpoint.clone()),
resolved_remote_addr: Some(endpoint_remote_display(endpoint)),
stats: None,
loss_rate: None,
is_client: true,
network_name: None,
is_closed: false,
secure_auth_level: None,
peer_identity_type: None,
}],
}
})
.collect()
}
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
value.map(|v| v as i64)
}
@@ -324,7 +193,7 @@ fn route_to_view(route: api::instance::Route) -> RouteView {
}
}
pub(crate) fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
let stats = conn.stats.map(|stats| PeerConnStats {
rx_bytes: stats.rx_bytes as i64,
tx_bytes: stats.tx_bytes as i64,
@@ -422,43 +291,3 @@ pub fn runtime_instance_from_running_info(
peers: info.peers.into_iter().map(peer_to_view).collect(),
}
}
pub fn runtime_instance_from_config_snapshot(
config_id: String,
display_name: String,
config: api::manage::NetworkConfig,
running: bool,
) -> RuntimeInstanceState {
let tun_attached = running && is_tun_attached(&config_id);
let tun_required =
running && (config.dev_name.as_deref().unwrap_or("") != "no_tun" || tun_attached);
let endpoint_urls = config_endpoint_urls(&config);
let public_server_url = non_empty_string(config.public_server_url.clone());
let my_node_info = MyNodeInfo {
virtual_ipv4: non_empty_string(config.virtual_ipv4.clone()),
virtual_ipv4_cidr: config_virtual_ipv4_cidr(&config),
hostname: non_empty_string(config.hostname.clone()),
version: None,
peer_id: None,
listeners: config.listener_urls.clone(),
vpn_portal_cfg: None,
udp_nat_type: None,
tcp_nat_type: None,
};
RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id,
display_name,
running,
tun_required,
tun_attached,
magic_dns_enabled: config.enable_magic_dns.unwrap_or(false),
need_exit_node: !config.exit_nodes.is_empty(),
error_message: None,
my_node_info: Some(my_node_info),
events: Vec::new(),
routes: configured_route_views(&endpoint_urls, public_server_url.as_deref()),
peers: configured_peer_views(&endpoint_urls),
}
}
+2 -2
View File
@@ -5,8 +5,8 @@
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint . --ignore-pattern src-tauri",
+23 -43
View File
@@ -654,8 +654,7 @@ mod manager {
#[derive(Default)]
pub(super) enum PersistedConfigSource {
User,
#[serde(alias = "webhook")]
Web,
Webhook,
#[serde(other)]
#[default]
Legacy,
@@ -665,15 +664,15 @@ mod manager {
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
match source {
ConfigSource::User => Self::User,
ConfigSource::Web => Self::Web,
ConfigSource::Webhook => Self::Webhook,
}
}
fn merge_persisted(self, incoming: Self) -> Self {
match (self, incoming) {
// Older runtimes report missing source as `user`. Keep the stronger persisted
// ownership until web sync or an explicit user save repairs it.
(Self::Web, Self::User) | (Self::Legacy, Self::User) => self,
// ownership until webhook sync or an explicit user save repairs it.
(Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self,
(_, next) => next,
}
}
@@ -681,13 +680,13 @@ mod manager {
fn to_runtime_source(self) -> ConfigSource {
match self {
Self::User | Self::Legacy => ConfigSource::User,
Self::Web => ConfigSource::Web,
Self::Webhook => ConfigSource::Webhook,
}
}
#[cfg(any(test, target_os = "android"))]
fn is_web_like(self) -> bool {
matches!(self, Self::Web)
fn is_webhook_like(self) -> bool {
matches!(self, Self::Webhook)
}
}
@@ -919,7 +918,7 @@ mod manager {
}
#[cfg(target_os = "android")]
pub fn get_enabled_instances_with_web_like_tun_ids(
pub fn get_enabled_instances_with_webhook_like_tun_ids(
&self,
) -> impl Iterator<Item = uuid::Uuid> + '_ {
self.storage
@@ -927,7 +926,7 @@ mod manager {
.iter()
.filter(|v| self.storage.enabled_networks.contains(v.key()))
.filter(|v| !v.config.no_tun())
.filter(|v| v.source.is_web_like())
.filter(|v| v.source.is_webhook_like())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
@@ -935,11 +934,12 @@ mod manager {
pub(super) async fn disable_instances_with_tun(
&self,
app: &AppHandle,
web_only: bool,
webhook_only: bool,
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
{
let inst_ids: Vec<uuid::Uuid> = if web_only {
self.get_enabled_instances_with_web_like_tun_ids().collect()
let inst_ids: Vec<uuid::Uuid> = if webhook_only {
self.get_enabled_instances_with_webhook_like_tun_ids()
.collect()
} else {
self.get_enabled_instances_with_tun_ids().collect()
};
@@ -977,7 +977,7 @@ mod manager {
.await
.map_err(|e| e.to_string())?;
}
PersistedConfigSource::Web => {
PersistedConfigSource::Webhook => {
self.disable_instances_with_tun(app, true)
.await
.map_err(|e| e.to_string())?;
@@ -1187,46 +1187,26 @@ mod manager {
}
#[test]
fn stored_gui_config_deserializes_webhook_source_as_web() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "webhook",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Web);
}
#[test]
fn stored_gui_config_defaults_unknown_source_to_legacy() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "unknown",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Legacy);
}
#[test]
fn persisted_source_merge_keeps_legacy_and_web_over_ambiguous_user() {
fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() {
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Legacy
);
assert_eq!(
PersistedConfigSource::Web.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Web
PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Webhook
);
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Web),
PersistedConfigSource::Web
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook),
PersistedConfigSource::Webhook
);
}
#[test]
fn only_web_configs_are_web_like() {
assert!(!PersistedConfigSource::Legacy.is_web_like());
assert!(!PersistedConfigSource::User.is_web_like());
assert!(PersistedConfigSource::Web.is_web_like());
fn only_webhook_configs_are_webhook_like() {
assert!(!PersistedConfigSource::Legacy.is_webhook_like());
assert!(!PersistedConfigSource::User.is_webhook_like());
assert!(PersistedConfigSource::Webhook.is_webhook_like());
}
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
import { type ConfigSource, normalizeConfigSource } from './config_source'
type NetworkConfig = NetworkTypes.NetworkConfig
type ValidateConfigResponse = Api.ValidateConfigResponse
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
type ConfigSource = 'user' | 'webhook' | 'legacy'
interface ServiceOptions {
config_dir: string
rpc_portal: string
@@ -31,14 +32,14 @@ function parseStoredConfigs(raw: string | null): StoredGuiConfig[] {
if (entry && typeof entry === 'object' && 'config' in entry) {
const { config, source } = entry as {
config?: NetworkConfig
source?: unknown
source?: ConfigSource
}
if (!config) {
return []
}
return [{
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
source: source === 'user' || source === 'webhook' ? source : 'legacy',
}]
}
@@ -1,13 +0,0 @@
export type ConfigSource = 'user' | 'web' | 'legacy'
export function normalizeConfigSource(source: unknown): ConfigSource {
if (source === 'user' || source === 'web' || source === 'legacy') {
return source
}
if (source === 'webhook') {
return 'web'
}
return 'legacy'
}
+2 -3
View File
@@ -2,11 +2,10 @@ import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib"
import { Utils } from "easytier-frontend-lib";
import { normalizeConfigSource } from './config_source'
interface StoredGuiConfig {
config: NetworkTypes.NetworkConfig
source?: unknown
source?: 'user' | 'webhook' | 'legacy'
}
const EVENTS = Object.freeze({
@@ -25,7 +24,7 @@ function onSaveConfigs(event: Event<StoredGuiConfig[]>) {
'networkList',
JSON.stringify(event.payload.map(({ config, source }) => ({
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
source: source ?? 'legacy',
}))),
);
}
+14 -11
View File
@@ -163,10 +163,14 @@ async function registerVpnServiceListener() {
)
}
function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] {
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] {
if (!routes) {
return []
}
const ret = []
for (const r of routes ?? []) {
for (let cidr of r.proxy_cidrs ?? []) {
for (const r of routes) {
for (let cidr of r.proxy_cidrs) {
if (!cidr.includes('/')) {
cidr += '/32'
}
@@ -174,9 +178,9 @@ function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.
}
}
for (const route of node_config.routes ?? []) {
ret.push(route)
}
node_config.routes.forEach(r => {
ret.push(r)
})
if (node_config.enable_magic_dns) {
ret.push('100.100.100.101/32')
@@ -211,15 +215,14 @@ export async function onNetworkInstanceChange(instanceId: string) {
console.log('vpn service skipped because no_tun is enabled', instanceId)
return
}
const curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId]
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
await doStopVpn()
return
}
const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4
const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined
const virtual_ip = Utils.ipv4ToString(curNetworkInfo?.my_node_info?.virtual_ipv4.address)
if (config.dhcp && (!virtual_ip || !virtual_ip.length)) {
console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms')
@@ -234,7 +237,7 @@ export async function onNetworkInstanceChange(instanceId: string) {
return
}
let network_length = virtualIpv4?.network_length
let network_length = curNetworkInfo?.my_node_info?.virtual_ipv4.network_length
if (!network_length) {
network_length = 24
}
@@ -287,7 +290,7 @@ async function isNoTunEnabled(instanceId: string | undefined) {
async function findRunningTunInstanceId() {
const instanceIds = await listNetworkInstanceIds()
const runningIds = (instanceIds.running_inst_ids ?? []).map(Utils.UuidToStr)
const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr)
console.log('vpn service sync running instances', JSON.stringify(runningIds))
for (const instanceId of runningIds) {
+2 -2
View File
@@ -9,7 +9,7 @@ export class GUIRemoteClient implements Api.RemoteClient {
await backend.runNetworkInstance(config, save);
}
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
return backend.collectNetworkInfo(inst_id).then(infos => infos.info?.map?.[inst_id]);
return backend.collectNetworkInfo(inst_id).then(infos => infos.info.map[inst_id]);
}
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
return backend.listNetworkInstanceIds();
@@ -44,4 +44,4 @@ export class GUIRemoteClient implements Api.RemoteClient {
return await backend.getNetworkMetas(instance_ids);
}
}
}
+5 -16
View File
@@ -13,18 +13,12 @@
"./*.css": "./dist/*.css"
},
"scripts": {
"codegen:proto": "node scripts/codegen-proto.mjs",
"dev": "pnpm codegen:proto && vite",
"build": "pnpm codegen:proto && vue-tsc -b && vite build",
"test": "pnpm test:config-ui && pnpm test:network-config",
"test:config-ui": "pnpm codegen:proto && vitest run --config vitest.config.ts",
"test:network-config": "pnpm build && node scripts/test-network-config.mjs",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"@protobuf-ts/runtime": "2.11.1",
"@protobuf-ts/runtime-rpc": "2.11.1",
"@vueuse/core": "^11.1.0",
"axios": "^1.13.5",
"chart.js": "^4.5.0",
@@ -39,13 +33,9 @@
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@protobuf-ts/plugin": "2.11.1",
"@protobuf-ts/protoc": "2.11.1",
"@types/node": "^22.8.6",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.20",
"happy-dom": "16.8.1",
"postcss": "^8.4.47",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
@@ -53,11 +43,10 @@
"typescript": "~5.6.3",
"vite": "^5.4.21",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.10"
},
"peerDependencies": {
"primevue": "^4.3.9",
"vue": "^3.5.12"
"vue": "^3.5.12",
"primevue": "^4.3.9"
}
}
}
+10 -2482
View File
File diff suppressed because it is too large Load Diff
@@ -1,121 +0,0 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { delimiter, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const protoRoot = resolve(root, '../../easytier/src/proto')
const generatedRoot = resolve(root, 'src/generated')
const outDir = resolve(generatedRoot, 'proto')
const nodeBinDir = resolve(root, 'node_modules/.bin')
const protocWrapper = require.resolve('@protobuf-ts/protoc/protoc.js')
const protobufTsPluginRoot = dirname(require.resolve('@protobuf-ts/plugin/package.json'))
const protoFiles = [
'common.proto',
'acl.proto',
'api_instance.proto',
'api_manage.proto',
'peer_rpc.proto',
'error.proto',
]
function installGeneratedFiles(fromDir, toDir) {
mkdirSync(toDir, { recursive: true })
for (const entry of readdirSync(fromDir)) {
const source = resolve(fromDir, entry)
const target = resolve(toDir, entry)
if (statSync(source).isDirectory()) {
installGeneratedFiles(source, target)
continue
}
renameSync(source, target)
}
}
function findExecutableInPath(command, extensions = ['']) {
const envPath = process.env[pathEnvKey()]
if (typeof envPath !== 'string') return undefined
const nodeBinSuffix = ['node_modules/.bin', 'node_modules\\.bin']
for (const entry of envPath.split(delimiter)) {
if (!entry || nodeBinSuffix.some((suffix) => entry.endsWith(suffix))) continue
for (const extension of extensions) {
const candidate = resolve(entry, `${command}${extension}`)
if (existsSync(candidate)) return candidate
}
}
return undefined
}
function pathEnvKey() {
return Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
}
function withNodeBinPath() {
const key = pathEnvKey()
const currentPath = process.env[key]
return {
...process.env,
[key]: currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir,
}
}
function getProtocCommand() {
const extensions = process.platform === 'win32' ? ['.exe'] : ['']
const systemProtoc = findExecutableInPath('protoc', extensions)
if (systemProtoc) {
return {
command: systemProtoc,
argsPrefix: ['--proto_path', protobufTsPluginRoot],
}
}
return {
command: process.execPath,
argsPrefix: [protocWrapper],
}
}
mkdirSync(generatedRoot, { recursive: true })
const tmpDir = mkdtempSync(resolve(generatedRoot, '.proto-'))
const protocCommand = getProtocCommand()
try {
const result = spawnSync(protocCommand.command, [
...protocCommand.argsPrefix,
'-I',
protoRoot,
`--ts_out=${tmpDir}`,
'--ts_opt=use_proto_field_name,server_none,client_none,ts_nocheck',
...protoFiles.map((file) => resolve(protoRoot, file)),
], {
cwd: root,
env: withNodeBinPath(),
stdio: 'inherit',
shell: false,
})
if (result.error) {
throw result.error
}
const status = result.status ?? 1
if (status === 0) {
installGeneratedFiles(tmpDir, outDir)
}
process.exit(status)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
@@ -1,540 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import ts from 'typescript'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(__dirname, '..')
const generatedApiManagePath = path.join(projectRoot, 'src/generated/proto/api_manage.ts')
const distPath = path.join(projectRoot, 'dist/easytier-frontend-lib.js')
const { NetworkTypes } = await import(pathToFileURL(distPath))
const {
AclAction,
AclChainType,
AclProtocol,
CompressionAlgoPb,
DEFAULT_NETWORK_CONFIG,
NetworkingMethod,
normalizeNetworkConfig,
toBackendNetworkConfig,
} = NetworkTypes
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
'disable_ipv6',
'enable_kcp_proxy',
'disable_kcp_input',
'disable_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'multi_thread',
'enable_relay_network_whitelist',
'enable_manual_routes',
'proxy_forward_by_system',
'disable_encryption',
'enable_socks5',
'disable_udp_hole_punching',
'enable_magic_dns',
'enable_private_mode',
'enable_quic_proxy',
'disable_quic_input',
'disable_sym_hole_punching',
'p2p_only',
'lazy_p2p',
'need_p2p',
'disable_upnp',
'ipv6_public_addr_provider',
'ipv6_public_addr_auto',
'disable_relay_data',
'enable_udp_broadcast_relay',
'disable_tcp_hole_punching',
]
function readGeneratedNetworkConfigFields() {
const source = ts.createSourceFile(
generatedApiManagePath,
fs.readFileSync(generatedApiManagePath, 'utf8'),
ts.ScriptTarget.Latest,
true,
)
for (const statement of source.statements) {
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'NetworkConfig') {
continue
}
return statement.members
.filter(ts.isPropertySignature)
.map((member) => member.name.getText(source).replace(/^['"]|['"]$/g, ''))
}
throw new Error(`NetworkConfig interface not found in ${generatedApiManagePath}`)
}
function expectNoCamelCaseKeys(value, pathSegments = []) {
if (!value || typeof value !== 'object') {
return
}
if (Array.isArray(value)) {
value.forEach((item, index) => expectNoCamelCaseKeys(item, [...pathSegments, String(index)]))
return
}
for (const [key, child] of Object.entries(value)) {
assert.equal(
/[A-Z]/.test(key),
false,
`JSON key should use proto field name: ${[...pathSegments, key].join('.')}`,
)
expectNoCamelCaseKeys(child, [...pathSegments, key])
}
}
function allFieldFixture() {
return {
...DEFAULT_NETWORK_CONFIG(),
instance_id: '11111111-2222-3333-4444-555555555555',
dhcp: false,
virtual_ipv4: '10.9.8.7',
network_length: 25,
hostname: 'frontend-e2e',
network_name: 'full-field-network',
network_secret: 'full-field-secret',
networking_method: NetworkingMethod.Manual,
public_server_url: 'tcp://public.example:11010',
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
peers: [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
],
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
enable_vpn_portal: true,
vpn_portal_listen_port: 23000,
vpn_portal_client_network_addr: '10.88.0.0',
vpn_portal_client_network_len: 24,
advanced_settings: true,
listener_urls: ['tcp://0.0.0.0:12010', 'udp://0.0.0.0:12010'],
latency_first: true,
dev_name: 'et-full',
use_smoltcp: true,
disable_ipv6: true,
enable_kcp_proxy: true,
disable_kcp_input: true,
disable_p2p: true,
bind_device: false,
no_tun: true,
enable_exit_node: true,
relay_all_peer_rpc: true,
multi_thread: false,
enable_relay_network_whitelist: true,
relay_network_whitelist: ['10.0.0.0/8', 'fd00::/8'],
enable_manual_routes: true,
routes: ['10.20.0.0/16', 'fd00:20::/64'],
exit_nodes: ['10.9.8.1', 'fd00::1'],
proxy_forward_by_system: true,
disable_encryption: true,
enable_socks5: true,
socks5_port: 1081,
disable_udp_hole_punching: true,
mtu: 1280,
mapped_listeners: ['tcp://127.0.0.1:13010'],
enable_magic_dns: true,
enable_private_mode: true,
enable_quic_proxy: true,
disable_quic_input: true,
quic_listen_port: 14010,
port_forwards: [
{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 8080,
dst_ip: '10.9.8.7',
dst_port: 80,
},
{
proto: 'udp',
bind_ip: '0.0.0.0',
bind_port: 5353,
dst_ip: '10.9.8.8',
dst_port: 53,
},
],
disable_sym_hole_punching: true,
p2p_only: true,
data_compress_algo: CompressionAlgoPb.Zstd,
encryption_algorithm: 'aes-gcm',
disable_tcp_hole_punching: true,
secure_mode: {
enabled: true,
local_private_key: 'private-key',
local_public_key: 'public-key',
},
acl: {
acl_v1: {
group: {
declares: [
{
group_name: 'ops',
group_secret: 'ops-secret',
},
],
members: ['node-a', 'node-b'],
},
chains: [
{
name: 'forward-chain',
chain_type: AclChainType.Forward,
description: 'forward traffic',
enabled: true,
default_action: AclAction.Drop,
rules: [
{
name: 'allow-web',
description: 'allow web traffic',
priority: 100,
enabled: true,
protocol: AclProtocol.TCP,
ports: ['80', '443'],
source_ips: ['10.0.0.0/8'],
destination_ips: ['10.9.8.7/32'],
source_ports: ['1024-65535'],
action: AclAction.Allow,
rate_limit: 1000,
burst_limit: 2000,
stateful: true,
source_groups: ['ops'],
destination_groups: ['web'],
},
],
},
],
},
},
credential_file: '/tmp/easytier-credential.toml',
lazy_p2p: true,
need_p2p: true,
instance_recv_bps_limit: '9007199254740993',
disable_upnp: true,
ipv6_public_addr_provider: true,
ipv6_public_addr_auto: true,
ipv6_public_addr_prefix: '2001:db8:1::/64',
disable_relay_data: true,
enable_udp_broadcast_relay: true,
socket_mark: 1234,
}
}
function assertFixtureCoversGeneratedFields() {
const generatedFields = readGeneratedNetworkConfigFields()
const fixtureFields = new Set(Object.keys(allFieldFixture()))
const missing = generatedFields.filter((field) => !fixtureFields.has(field))
assert.deepEqual(missing, [], 'all generated NetworkConfig fields should be represented in the fixture')
}
function assertFullFieldRoundTrip() {
const input = allFieldFixture()
const normalized = normalizeNetworkConfig(input)
assert.equal(normalized.peer_urls.join(','), 'tcp://peer-a:11010,udp://peer-b:11010')
assert.equal(normalized.instance_recv_bps_limit, '9007199254740993')
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
const backend = toBackendNetworkConfig(normalized)
expectNoCamelCaseKeys(backend)
for (const field of readGeneratedNetworkConfigFields()) {
assert.ok(field in backend, `backend JSON should include fixture field ${field}`)
}
assert.equal(backend.networking_method, 'Manual')
assert.equal(backend.public_server_url, '')
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
assert.equal(backend.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(backend.peers[1], { uri: 'udp://peer-b:11010' })
assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.secure_mode.enabled, true)
assert.equal(backend.secure_mode.local_private_key, 'private-key')
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
assert.equal(backend.acl.acl_v1.chains[0].default_action, 'Drop')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].action, 'Allow')
assert.equal(backend.port_forwards[1].proto, 'udp')
assert.equal(backend.socket_mark, 1234)
}
function assertBooleanFieldValuesPreserved() {
const input = allFieldFixture()
const normalized = normalizeNetworkConfig(input)
const backend = toBackendNetworkConfig(normalized)
for (const field of BOOLEAN_CONFIG_FIELDS) {
assert.equal(
normalized[field],
input[field],
`normalized config should preserve boolean field ${field}`,
)
assert.equal(
backend[field],
input[field],
`backend JSON should preserve boolean field ${field}`,
)
}
}
function assertEnumCompatibility() {
const normalized = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'Manual',
data_compress_algo: 'Zstd',
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 'Forward',
default_action: 'Drop',
rules: [
{
protocol: 'TCP',
action: 'Allow',
},
],
},
],
},
},
})
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
assert.equal(normalized.acl.acl_v1.chains[0].default_action, AclAction.Drop)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].action, AclAction.Allow)
const backend = toBackendNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
data_compress_algo: 'Zstd',
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 'Forward',
default_action: 'Drop',
rules: [
{
protocol: 'TCP',
action: 'Allow',
},
],
},
],
},
},
})
assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
}
function assertAclDefaultsAndExplicitZero() {
const partialAcl = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [{ rules: [{}] }],
},
},
})
const defaultedChain = partialAcl.acl.acl_v1.chains[0]
assert.equal(defaultedChain.chain_type, AclChainType.UnspecifiedChain)
assert.equal(defaultedChain.default_action, AclAction.Allow)
assert.equal(defaultedChain.rules[0].protocol, AclProtocol.Any)
assert.equal(defaultedChain.rules[0].action, AclAction.Allow)
const explicitZero = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 0,
default_action: 0,
rules: [{ protocol: 0, action: 0 }],
},
],
},
},
})
const zeroChain = explicitZero.acl.acl_v1.chains[0]
assert.equal(zeroChain.chain_type, AclChainType.UnspecifiedChain)
assert.equal(zeroChain.default_action, AclAction.Noop)
assert.equal(zeroChain.rules[0].protocol, AclProtocol.Unspecified)
assert.equal(zeroChain.rules[0].action, AclAction.Noop)
}
function assertNetworkingMethodNormalization() {
const publicServer = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'PublicServer',
public_server_url: ' tcp://public.example:11010 ',
peer_urls: ['tcp://manual.example:11010'],
})
assert.equal(publicServer.networking_method, NetworkingMethod.Manual)
assert.equal(publicServer.public_server_url, '')
assert.deepEqual(publicServer.peer_urls, ['tcp://public.example:11010'])
const standalone = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'Standalone',
peer_urls: ['tcp://manual.example:11010'],
})
assert.equal(standalone.networking_method, NetworkingMethod.Manual)
assert.deepEqual(standalone.peer_urls, [])
const missing = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: undefined,
peer_urls: [' tcp://one ', '', 'udp://two '],
})
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
const publicServerMissingUrl = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'PublicServer',
public_server_url: '',
peer_urls: ['tcp://manual.example:11010'],
})
assert.deepEqual(publicServerMissingUrl.peer_urls, [])
}
function assertPeerPublicKeysPreserved() {
const normalized = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
peer_urls: [],
peers: [
{
uri: ' tcp://peer-a:11010 ',
peer_public_key: 'peer-a-public-key',
},
],
})
assert.deepEqual(normalized.peer_urls, ['tcp://peer-a:11010'])
assert.deepEqual(normalized.peers, [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
])
const unchangedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-a:11010', 'tcp://peer-b:11010'],
})
assert.equal(unchangedUrl.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(unchangedUrl.peers[1], { uri: 'tcp://peer-b:11010' })
const changedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-c:11010'],
})
assert.deepEqual(changedUrl.peers, [{ uri: 'tcp://peer-c:11010' }])
const clearedUrls = toBackendNetworkConfig({
...normalized,
peer_urls: [],
})
assert.deepEqual(clearedUrls.peer_urls ?? [], [])
assert.deepEqual(clearedUrls.peers ?? [], [])
}
function assertNumberBoundaries() {
const safeLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '12345',
})
assert.equal(safeLimit.instance_recv_bps_limit, 12345)
const largeLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '9007199254740993',
})
assert.equal(largeLimit.instance_recv_bps_limit, '9007199254740993')
assert.equal(toBackendNetworkConfig(largeLimit).instance_recv_bps_limit, '9007199254740993')
const invalidNumbers = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
mtu: Number.NaN,
instance_recv_bps_limit: Number.POSITIVE_INFINITY,
})
assert.equal(invalidNumbers.mtu, null)
assert.equal(invalidNumbers.instance_recv_bps_limit, null)
const emptyLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '',
})
assert.equal(emptyLimit.instance_recv_bps_limit, null)
const zeroLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '0',
})
assert.equal(zeroLimit.instance_recv_bps_limit, null)
assert.equal(toBackendNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: 0,
}).instance_recv_bps_limit, undefined)
const oversizedLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '18446744073709551616',
})
assert.equal(oversizedLimit.instance_recv_bps_limit, null)
}
const tests = [
assertFixtureCoversGeneratedFields,
assertFullFieldRoundTrip,
assertBooleanFieldValuesPreserved,
assertEnumCompatibility,
assertAclDefaultsAndExplicitZero,
assertNetworkingMethodNormalization,
assertPeerPublicKeysPreserved,
assertNumberBoundaries,
]
for (const test of tests) {
test()
console.log(`ok ${test.name}`)
}
@@ -9,7 +9,7 @@ import {
normalizeNetworkConfig,
removeRow
} from '../types/network'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AclManager from './acl/AclManager.vue'
import UrlListInput from './UrlListInput.vue'
@@ -134,7 +134,6 @@ function savePortForward() {
const portForwardContainer = ref<HTMLElement | null>(null);
const isCompact = ref(false);
const UINT64_MAX = (1n << 64n) - 1n
onMounted(() => {
if (portForwardContainer.value) {
@@ -162,39 +161,6 @@ function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
}
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
function parseInstanceRecvBpsLimitInput(value: string): number | string | null | undefined {
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
if (!/^\d+$/.test(trimmed)) {
return undefined
}
const limit = BigInt(trimmed)
if (limit === 0n) {
return null
}
if (limit > UINT64_MAX) {
return undefined
}
return limit <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(limit) : limit.toString()
}
const instanceRecvBpsLimitInput = computed<string>({
get: () => {
const limit = curNetwork.value.instance_recv_bps_limit
return limit == null ? '' : String(limit)
},
set: (value) => {
const limit = parseInstanceRecvBpsLimitInput(value)
if (limit !== undefined) {
curNetwork.value.instance_recv_bps_limit = limit
}
},
})
</script>
<template>
@@ -351,9 +317,9 @@ const instanceRecvBpsLimitInput = computed<string>({
<span class="pi pi-question-circle ml-2 self-center"
v-tooltip="t('instance_recv_bps_limit_help')"></span>
</div>
<InputText id="instance_recv_bps_limit" v-model="instanceRecvBpsLimitInput"
aria-describedby="instance_recv_bps_limit-help" inputmode="numeric" pattern="[0-9]*"
:placeholder="t('instance_recv_bps_limit_placeholder')" fluid />
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
aria-describedby="instance_recv_bps_limit-help" :format="false"
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
</div>
</div>
@@ -35,7 +35,7 @@ const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefin
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const isRunning = (instanceId: string) => {
return (listInstanceIdResponse.value?.running_inst_ids ?? []).map(Utils.UuidToStr).includes(instanceId);
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId);
}
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
@@ -46,7 +46,7 @@ const loadNetworkMetas = async (instanceIds: string[]) => {
try {
const response = await props.api.get_network_metas(missingIds);
Object.assign(networkMetaCache.value, response.metas ?? {});
Object.assign(networkMetaCache.value, response.metas);
} catch (e) {
console.error("Failed to load network metas", e);
}
@@ -80,8 +80,8 @@ const updateInstanceList = () => {
let insts = new Set<string>();
let t = listInstanceIdResponse.value;
if (t) {
(t.running_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
(t.disabled_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
}
const newList = Array.from(insts).map((instance: string) => {
@@ -149,7 +149,7 @@ const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) {
return false;
}
return (listInstanceIdResponse.value?.disabled_inst_ids ?? []).map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
});
watch(networkIsDisabled, async (newVal, oldVal) => {
if (newVal !== oldVal && newVal === true) {
@@ -287,35 +287,17 @@ const loadNetworkInstanceIds = async () => {
}
const loadCurrentNetworkInfo = async () => {
const selected = selectedInstanceId.value?.uuid;
if (!selected) {
curNetworkInfo.value = null;
if (!selectedInstanceId.value) {
return;
}
if (!needShowNetworkStatus.value) {
curNetworkInfo.value = null;
return;
}
if (curNetworkInfo.value?.instance_id !== selected) {
curNetworkInfo.value = null;
}
let network_info = await props.api.get_network_info(selected);
if (selectedInstanceId.value?.uuid !== selected) {
return;
}
if (!network_info) {
curNetworkInfo.value = {
instance_id: selected,
running: false,
error_msg: t('web.device_management.network_info_unavailable'),
} as NetworkTypes.NetworkInstance;
return;
}
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid);
curNetworkInfo.value = {
instance_id: selected,
instance_id: selectedInstanceId.value.uuid,
running: network_info?.running ?? false,
error_msg: network_info?.error_msg ?? '',
detail: network_info,
@@ -510,7 +492,7 @@ onUnmounted(() => {
<div class="flex items-center min-w-0">
<div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{
slotProps.option.meta?.network_name ?? slotProps.option.uuid }}</span>
slotProps.option.meta.network_name }}</span>
</div>
<Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
@@ -587,13 +569,10 @@ onUnmounted(() => {
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div>
<Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo"
class="mb-4">
</Status>
<Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
curNetworkInfo.error_msg }}</Message>
<Message v-else severity="info" class="mb-4">{{ t('web.device_management.loading_network_status') }}
</Message>
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message>
<div class="text-center mt-4">
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { useTimeAgo } from '@vueuse/core'
import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
import NetworkChart from './NetworkChart.vue';
@@ -39,8 +39,8 @@ function routeCost(info: any) {
return '?'
}
function resolveObjPath(path: string, obj: any = globalThis, separator = '.') {
const properties = path.split(separator)
function resolveObjPath(path: string, obj = globalThis, separator = '.') {
const properties = Array.isArray(path) ? path : path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj)
}
@@ -48,17 +48,10 @@ function statsCommon(info: any, field: string): number | undefined {
if (!info.peer)
return undefined
let sum = 0
let hasValue = false
for (const conn of peerConns(info)) {
const value = numericValue(resolveObjPath(field, conn))
if (value === undefined)
continue
sum += value
hasValue = true
}
return hasValue ? sum : undefined
const conns = info.peer.conns
return conns.reduce((acc: number, conn: any) => {
return acc + resolveObjPath(field, conn)
}, 0)
}
function humanFileSize(bytes: number, si = false, dp = 1) {
@@ -81,6 +74,14 @@ function humanFileSize(bytes: number, si = false, dp = 1) {
return `${bytes.toFixed(dp)} ${units[u]}`
}
function latencyMs(info: PeerRoutePair) {
let lat_us_sum = statsCommon(info, 'stats.latency_us')
if (lat_us_sum === undefined)
return ''
lat_us_sum = lat_us_sum / 1000 / info.peer!.conns.length
return `${lat_us_sum % 1 > 0 ? Math.round(lat_us_sum) + 1 : Math.round(lat_us_sum)}ms`
}
function txBytes(info: PeerRoutePair) {
const tx = statsCommon(info, 'stats.tx_bytes')
return tx ? humanFileSize(tx) : ''
@@ -91,6 +92,11 @@ function rxBytes(info: PeerRoutePair) {
return rx ? humanFileSize(rx) : ''
}
function lossRate(info: PeerRoutePair) {
const lossRate = statsCommon(info, 'loss_rate')
return lossRate !== undefined ? `${Math.round(lossRate * 100)}%` : ''
}
function version(info: PeerRoutePair) {
return info.route.version === '' ? 'unknown' : info.route.version
}
@@ -99,7 +105,7 @@ function ipFormat(info: PeerRoutePair) {
const ip = info.route.ipv4_addr
if (typeof ip === 'string')
return ip
return ip ? ipv4InetToString(ip) : ''
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : ''
}
function oneTunnelProto(tunnel?: TunnelInfo): string {
@@ -125,7 +131,7 @@ function oneTunnelProto(tunnel?: TunnelInfo): string {
}
function tunnelProto(info: PeerRoutePair) {
return [...new Set(peerConns(info).map(c => oneTunnelProto(c.tunnel)))].join(',')
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',')
}
const myNodeInfo = computed(() => {
@@ -200,7 +206,7 @@ const myNodeInfoChips = computed(() => {
// local ipv4s
const local_ipv4s = my_node_info.ips?.interface_ipv4s
for (const [idx, ip] of local_ipv4s?.entries() ?? []) {
for (const [idx, ip] of local_ipv4s?.entries()) {
chips.push({
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
icon: '',
@@ -209,7 +215,7 @@ const myNodeInfoChips = computed(() => {
// local ipv6s
const local_ipv6s = my_node_info.ips?.interface_ipv6s
for (const [idx, ip] of local_ipv6s?.entries() ?? []) {
for (const [idx, ip] of local_ipv6s?.entries()) {
chips.push({
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
icon: '',
@@ -220,7 +226,7 @@ const myNodeInfoChips = computed(() => {
const public_ip = my_node_info.ips?.public_ipv4
if (public_ip) {
chips.push({
label: `Public IP: ${ipv4ToString(public_ip)}`,
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`,
icon: '',
} as Chip)
}
@@ -235,7 +241,7 @@ const myNodeInfoChips = computed(() => {
// listeners:
const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries() ?? []) {
for (const [idx, listener] of listeners?.entries()) {
chips.push({
label: `Listener ${idx}: ${listener.url}`,
icon: '',
@@ -282,14 +288,6 @@ function natType(info: PeerRoutePair): string {
return ''
}
function isPublicServerRoute(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.is_public_server ?? false
}
function shouldAvoidRelayData(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.avoid_relay_data ?? false
}
const peerCount = computed(() => {
if (!peerRouteInfos.value)
return 0
@@ -344,7 +342,7 @@ function showEventLogs() {
if (!detail)
return
dialogContent.value = detail.events?.map((event: string) => JSON.parse(event)) ?? []
dialogContent.value = detail.events.map((event: string) => JSON.parse(event))
dialogHeader.value = 'event_log'
dialogVisible.value = true
}
@@ -436,16 +434,16 @@ function showEventLogs() {
<Column :field="ipFormat" :header="t('virtual_ipv4')" />
<Column :header="t('hostname')">
<template #body="slotProps">
<div v-if="!slotProps.data.route.cost || !isPublicServerRoute(slotProps.data)"
<div v-if="!slotProps.data.route.cost || !slotProps.data.route.feature_flag.is_public_server"
v-tooltip="slotProps.data.route.hostname">
{{
slotProps.data.route.hostname }}
</div>
<div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1">
<Tag v-if="isPublicServerRoute(slotProps.data)" severity="info" value="Info">
<Tag v-if="slotProps.data.route.feature_flag.is_public_server" severity="info" value="Info">
{{ t('status.server') }}
</Tag>
<Tag v-if="shouldAvoidRelayData(slotProps.data)" severity="warn" value="Warn">
<Tag v-if="slotProps.data.route.feature_flag.avoid_relay_data" severity="warn" value="Warn">
{{ t('status.relay') }}
</Tag>
</div>
@@ -2,7 +2,7 @@
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule, ensureAclChain, ensureAclRuleLists } from '../../types/network'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network'
import AclRuleDialog from './AclRuleDialog.vue'
const props = defineProps<{
@@ -13,11 +13,7 @@ const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n()
function rules() {
return ensureAclChain(chain.value).rules
}
watch(() => rules(), (newRules) => {
watch(() => chain.value.rules, (newRules) => {
if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) {
@@ -64,7 +60,7 @@ function addRule() {
editingRule.value = {
name: '',
description: '',
priority: rules().length,
priority: chain.value.rules.length,
enabled: true,
protocol: AclProtocol.Any,
ports: [],
@@ -83,31 +79,28 @@ function addRule() {
function editRule(index: number) {
editingRuleIndex.value = index
editingRule.value = ensureAclRuleLists(JSON.parse(JSON.stringify(rules()[index])))
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index]))
showRuleDialog.value = true
}
function deleteRule(index: number) {
rules().splice(index, 1)
chain.value.rules.splice(index, 1)
}
function saveRule(rule: AclRule) {
const chainRules = rules()
ensureAclRuleLists(rule)
if (editingRuleIndex.value === -1) {
chainRules.push(rule)
chain.value.rules.push(rule)
} else {
chainRules[editingRuleIndex.value] = rule
chain.value.rules[editingRuleIndex.value] = rule
}
chainRules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
}
function onRowReorder(event: any) {
chain.value.rules = event.value ?? []
const chainRules = rules()
chain.value.rules = event.value
// Update priorities based on new order (higher priority at top)
chainRules.forEach((rule, index) => {
rule.priority = chainRules.length - index - 1
chain.value.rules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1
})
}
</script>
@@ -150,7 +143,7 @@ function onRowReorder(event: any) {
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
</div>
<DataTable :value="rules()" @row-reorder="onRowReorder" responsiveLayout="scroll">
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }">
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { GroupIdentity, GroupInfo, ensureGroupInfo } from '../../types/network';
import { GroupIdentity, GroupInfo } from '../../types/network';
const props = defineProps<{
groupNames?: string[]
@@ -18,17 +18,6 @@ const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false)
const oldGroupName = ref('')
function groupInfo() {
return ensureGroupInfo(group.value)
}
const members = computed({
get: () => groupInfo().members,
set: value => {
groupInfo().members = value
},
})
function addGroup() {
editingGroupIndex.value = -1
editingGroup.value = {
@@ -41,13 +30,13 @@ function addGroup() {
function editGroup(index: number) {
editingGroupIndex.value = index
editingGroup.value = JSON.parse(JSON.stringify(groupInfo().declares[index]))
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index]))
oldGroupName.value = editingGroup.value?.group_name || ''
showGroupDialog.value = true
}
function deleteGroup(index: number) {
groupInfo().declares.splice(index, 1)
group.value.declares.splice(index, 1)
}
function saveGroup() {
@@ -55,15 +44,15 @@ function saveGroup() {
const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) {
groupInfo().declares.push(editingGroup.value)
group.value.declares.push(editingGroup.value)
} else {
if (oldGroupName.value && oldGroupName.value !== newName) {
// Sync in members
groupInfo().members = groupInfo().members.map(m => m === oldGroupName.value ? newName : m)
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m)
// Notify parent to sync in rules
emit('rename-group', { oldName: oldGroupName.value, newName })
}
groupInfo().declares[editingGroupIndex.value] = editingGroup.value
group.value.declares[editingGroupIndex.value] = editingGroup.value
}
showGroupDialog.value = false
}
@@ -81,7 +70,7 @@ function saveGroup() {
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</div>
<DataTable :value="groupInfo().declares" responsiveLayout="scroll">
<DataTable :value="group.declares" responsiveLayout="scroll">
<Column field="group_name" :header="t('acl.group.name')" />
<Column field="group_secret" :header="t('acl.group.secret')">
<template #body="{ data }">
@@ -101,7 +90,7 @@ function saveGroup() {
<div class="flex flex-col gap-2">
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
<MultiSelect v-model="members" :options="props.groupNames" multiple fluid filter
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.group.members')" />
</div>
@@ -2,7 +2,7 @@
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Acl, AclAction, AclChainType, ensureAclV1 } from '../../types/network'
import { Acl, AclAction, AclChainType } from '../../types/network'
import AclChainEditor from './AclChainEditor.vue'
import AclGroupEditor from './AclGroupEditor.vue'
@@ -12,7 +12,6 @@ const { t } = useI18n()
const activeTab = ref(0)
const menu = ref()
const aclV1 = computed(() => ensureAclV1(acl.value))
const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
@@ -21,6 +20,10 @@ const addMenuModel = ref([
])
function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = ''
switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break;
@@ -28,7 +31,7 @@ function addChain(type: AclChainType) {
case AclChainType.Forward: defaultName = 'Forward'; break;
}
aclV1.value.chains.push({
acl.value.acl_v1.chains.push({
name: defaultName,
chain_type: type,
description: '',
@@ -37,20 +40,21 @@ function addChain(type: AclChainType) {
default_action: AclAction.Allow
})
activeTab.value = aclV1.value.chains.length - 1
activeTab.value = acl.value.acl_v1.chains.length - 1
}
function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) {
aclV1.value.chains.splice(index, 1)
if (activeTab.value >= aclV1.value.chains.length) {
activeTab.value = Math.max(0, aclV1.value.chains.length)
acl.value.acl_v1?.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
}
}
}
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
aclV1.value.chains.forEach(chain => {
if (!acl.value.acl_v1) return
acl.value.acl_v1.chains.forEach(chain => {
chain.rules.forEach(rule => {
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
@@ -59,11 +63,11 @@ function handleRenameGroup({ oldName, newName }: { oldName: string, newName: str
}
const groupNames = computed(() => {
return aclV1.value.group?.declares.map(g => g.group_name) || []
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || []
})
const tabs = computed(() => {
const chains = aclV1.value.chains
const chains = acl.value.acl_v1?.chains || []
const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) {
@@ -120,13 +124,24 @@ const tabs = computed(() => {
</div>
<!-- Rule Chains -->
<div v-if="tab.type === 'chain' && aclV1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="aclV1.chains[tab.index]" :group-names="groupNames" />
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
</div>
<!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4">
<AclGroupEditor v-model="aclV1.group" :group-names="groupNames" @rename-group="handleRenameGroup" />
<template v-if="acl.acl_v1">
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
@rename-group="handleRenameGroup" />
<div v-else class="flex justify-center p-4">
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
@click="acl.acl_v1.group = { declares: [], members: [] }" />
</div>
</template>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
</div>
</div>
</TabPanel>
</TabPanels>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AclAction, AclProtocol, AclRule, ensureAclRuleLists } from '../../types/network';
import { AclAction, AclProtocol, AclRule } from '../../types/network';
const props = defineProps<{
visible: boolean
@@ -32,8 +32,6 @@ const showPorts = computed(() => {
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
})
watch(() => rule.value, ensureAclRuleLists, { immediate: true })
function close() {
emit('update:visible', false)
}
@@ -341,8 +341,6 @@ web:
import_config: 导入配置
create_new: 创建新网络
network_status: 网络状态
loading_network_status: 正在加载网络状态
network_info_unavailable: 网络状态不可用
network_configuration: 网络配置
loading_network_configuration: 加载网络配置
no_network_selected: 未选择网络
@@ -341,8 +341,6 @@ web:
import_config: Import Config
create_new: Create New Network
network_status: Network Status
loading_network_status: Loading Network Status
network_info_unavailable: Network status is unavailable
network_configuration: Network Configuration
loading_network_configuration: Loading Network Configuration
no_network_selected: No Network Selected
@@ -1,82 +0,0 @@
import type { PeerRoutePair } from '../types/network'
export function numericValue(value: unknown): number | undefined {
if (typeof value === 'number')
return Number.isFinite(value) ? value : undefined
if (typeof value !== 'string' || value.trim() === '')
return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
export function peerConns(info: PeerRoutePair) {
return info.peer?.conns || []
}
function defaultConnId(info: PeerRoutePair) {
const defaultConn = info.peer?.default_conn_id
if (!defaultConn)
return undefined
const part1 = defaultConn.part1 ?? 0
const part2 = defaultConn.part2 ?? 0
const part3 = defaultConn.part3 ?? 0
const part4 = defaultConn.part4 ?? 0
if (part1 === 0 && part2 === 0 && part3 === 0 && part4 === 0)
return undefined
const toHex = (value: number) => value.toString(16).padStart(8, '0')
const part1Hex = toHex(part1)
const part2Hex = toHex(part2)
const part3Hex = toHex(part3)
const part4Hex = toHex(part4)
return `${part1Hex}-${part2Hex.slice(0, 4)}-${part2Hex.slice(4, 8)}-${part3Hex.slice(0, 4)}-${part3Hex.slice(4, 8)}${part4Hex}`
}
function defaultConnFirst(info: PeerRoutePair) {
const conns = peerConns(info)
const connId = defaultConnId(info)
if (!connId)
return conns
const defaultConn = conns.find(conn => conn.conn_id === connId)
return defaultConn ? [defaultConn, ...conns.filter(conn => conn !== defaultConn)] : conns
}
export function latencyMs(info: PeerRoutePair) {
const connId = defaultConnId(info)
let minLatencyUs: number | undefined
for (const conn of peerConns(info)) {
if (!conn.stats)
continue
const latencyUs = numericValue(conn.stats.latency_us)
if (latencyUs === undefined)
continue
if (connId === conn.conn_id)
return `${Math.ceil(latencyUs / 1000)}ms`
minLatencyUs = Math.min(minLatencyUs ?? latencyUs, latencyUs)
}
if (minLatencyUs === undefined)
return ''
return `${Math.ceil(minLatencyUs / 1000)}ms`
}
export function lossRate(info: PeerRoutePair) {
for (const conn of defaultConnFirst(info)) {
const loss = numericValue(conn.loss_rate)
if (loss === undefined)
continue
return `${Math.round(loss * 100)}%`
}
return ''
}
+17 -27
View File
@@ -1,30 +1,24 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber'
import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
export function ipv4ToString(ip: Ipv4Addr | null | undefined) {
if (!ip) {
return ''
}
return IPv4.fromNumber(ip.addr ?? 0).toString()
export function ipv4ToString(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr).toString()
}
export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === undefined) {
return 'undefined'
}
return `${ipv4ToString(ip.address)}/${ip.network_length ?? 0}`
return `${ipv4ToString(ip.address)}/${ip.network_length}`
}
export function ipv6ToString(ip: Ipv6Addr | null | undefined) {
if (!ip) {
return ''
}
export function ipv6ToString(ip: Ipv6Addr) {
return IPv6.fromBigInt(
(BigInt(ip.part1 ?? 0) << BigInt(96))
+ (BigInt(ip.part2 ?? 0) << BigInt(64))
+ (BigInt(ip.part3 ?? 0) << BigInt(32))
+ BigInt(ip.part4 ?? 0),
).toString()
(BigInt(ip.part1) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64))
+ (BigInt(ip.part3) << BigInt(32))
+ BigInt(ip.part4),
)
}
function toHexString(uint64: bigint, padding = 9): string {
@@ -49,17 +43,14 @@ function uint32ToUuid(part1: number, part2: number, part3: number, part4: number
}
export interface UUID {
part1?: number;
part2?: number;
part3?: number;
part4?: number;
part1: number;
part2: number;
part3: number;
part4: number;
}
export function UuidToStr(uuid: UUID | null | undefined): string {
if (!uuid) {
return '';
}
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
export function UuidToStr(uuid: UUID): string {
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
}
export interface Location {
@@ -80,12 +71,11 @@ export interface DeviceInfo {
}
export function buildDeviceInfo(device: any): DeviceInfo {
const runningInstances = device.info?.running_network_instances ?? [];
let dev_info: DeviceInfo = {
hostname: device.info?.hostname,
public_ip: device.client_url,
running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)),
running_network_count: runningInstances.length,
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length,
report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id),
+180 -239
View File
@@ -1,80 +1,166 @@
import { v4 as uuidv4 } from 'uuid'
import {
NetworkConfig as NetworkConfigPb,
NetworkingMethod,
type NetworkPeerConfig,
type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig,
} from '../generated/proto/api_manage'
import {
Action as AclAction,
ChainType as AclChainType,
Protocol as AclProtocol,
type Acl,
type AclV1,
type Chain as AclChain,
type GroupIdentity,
type GroupInfo,
type Rule as AclRule,
} from '../generated/proto/acl'
import {
CompressionAlgoPb,
NatType,
type PeerFeatureFlag,
type SecureModeConfig,
} from '../generated/proto/common'
import { prepareNetworkConfigForProtoJson } from './networkCompat'
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig }
export enum NetworkingMethod {
PublicServer = 0,
Manual = 1,
Standalone = 2,
}
export type NetworkConfig = Omit<
ProtoNetworkConfig,
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
> & {
export interface SecureModeConfig {
enabled: boolean
// Keep protocol compatibility with backend/import-export flows even though the GUI
// does not render secure-mode or credential inputs.
local_private_key?: string
local_public_key?: string
}
export enum AclProtocol {
Unspecified = 0,
TCP = 1,
UDP = 2,
ICMP = 3,
ICMPv6 = 4,
Any = 5,
}
export enum AclAction {
Noop = 0,
Allow = 1,
Drop = 2,
}
export enum AclChainType {
UnspecifiedChain = 0,
Inbound = 1,
Outbound = 2,
Forward = 3,
}
export interface AclRule {
name: string
description: string
priority: number
enabled: boolean
protocol: AclProtocol
ports: string[]
source_ips: string[]
destination_ips: string[]
source_ports: string[]
action: AclAction
rate_limit: number
burst_limit: number
stateful: boolean
source_groups: string[]
destination_groups: string[]
}
export interface AclChain {
name: string
chain_type: AclChainType
description: string
enabled: boolean
rules: AclRule[]
default_action: AclAction
}
export interface GroupIdentity {
group_name: string
group_secret: string
}
export interface GroupInfo {
declares: GroupIdentity[]
members: string[]
}
export interface AclV1 {
chains: AclChain[]
group?: GroupInfo
}
export interface Acl {
acl_v1?: AclV1
}
export interface NetworkConfig {
instance_id: string
mtu: number | null
instance_recv_bps_limit: number | string | null
networking_method: NetworkingMethod | string
}
export type NormalizedAclV1 = AclV1 & {
group: GroupInfo
}
dhcp: boolean
virtual_ipv4: string
network_length: number
hostname?: string
network_name: string
network_secret?: string
credential_file?: string
secure_mode?: SecureModeConfig
const UINT64_MAX = (1n << 64n) - 1n
networking_method: NetworkingMethod
interface NetworkingConfigFields {
public_server_url: string
peer_urls: string[]
peers?: NetworkPeerConfig[]
public_server_url?: string
networking_method?: NetworkingMethod | string
}
interface NetworkingMethodOptions {
fillPeerUrlsFromPeers?: boolean
}
proxy_cidrs: string[]
function emptyGroupInfo(): GroupInfo {
return {
declares: [],
members: [],
}
}
enable_vpn_portal: boolean
vpn_portal_listen_port: number
vpn_portal_client_network_addr: string
vpn_portal_client_network_len: number
function emptyAcl(): Acl {
return {
acl_v1: {
group: emptyGroupInfo(),
chains: [],
},
}
advanced_settings: boolean
listener_urls: string[]
latency_first: boolean
dev_name: string
use_smoltcp?: boolean
disable_ipv6?: boolean
ipv6_public_addr_auto?: boolean
enable_kcp_proxy?: boolean
disable_kcp_input?: boolean
enable_quic_proxy?: boolean
disable_quic_input?: boolean
disable_p2p?: boolean
p2p_only?: boolean
lazy_p2p?: boolean
bind_device?: boolean
no_tun?: boolean
enable_exit_node?: boolean
relay_all_peer_rpc?: boolean
need_p2p?: boolean
multi_thread?: boolean
proxy_forward_by_system?: boolean
disable_encryption?: boolean
disable_tcp_hole_punching?: boolean
disable_udp_hole_punching?: boolean
disable_upnp?: boolean
enable_udp_broadcast_relay?: boolean
disable_sym_hole_punching?: boolean
enable_relay_network_whitelist?: boolean
relay_network_whitelist: string[]
enable_manual_routes: boolean
routes: string[]
exit_nodes: string[]
enable_socks5?: boolean
socks5_port: number
mtu: number | null
instance_recv_bps_limit: number | null
mapped_listeners: string[]
enable_magic_dns?: boolean
enable_private_mode?: boolean
port_forwards: PortForwardConfig[]
acl?: Acl
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
return {
...NetworkConfigPb.create(),
instance_id: uuidv4(),
dhcp: true,
@@ -141,7 +227,15 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_magic_dns: false,
enable_private_mode: false,
port_forwards: [],
acl: emptyAcl(),
acl: {
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
}
}
@@ -149,185 +243,33 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
}
function cleanNetworkPeers(peers: NetworkPeerConfig[] | undefined): NetworkPeerConfig[] {
return (peers ?? [])
.map((peer) => ({
...peer,
uri: peer.uri.trim(),
}))
.filter((peer) => peer.uri.length > 0)
}
function peersFromUrls(urls: string[], existingPeers: NetworkPeerConfig[]): NetworkPeerConfig[] {
const peersByUri = new Map<string, NetworkPeerConfig>()
for (const peer of existingPeers) {
if (!peersByUri.has(peer.uri)) {
peersByUri.set(peer.uri, peer)
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized: NetworkConfig = {
...config,
peer_urls: cleanPeerUrls(config.peer_urls),
}
return urls.map((uri) => ({
...(peersByUri.get(uri) ?? {}),
uri,
}))
}
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
export function ensureAclRuleLists(rule: AclRule): AclRule {
rule.ports ??= []
rule.source_ips ??= []
rule.destination_ips ??= []
rule.source_ports ??= []
rule.source_groups ??= []
rule.destination_groups ??= []
return rule
}
export function ensureAclChain(chain: AclChain): AclChain {
chain.rules ??= []
chain.rules.forEach(ensureAclRuleLists)
return chain
}
export function ensureGroupInfo(group: GroupInfo): GroupInfo {
group.declares ??= []
group.members ??= []
return group
}
export function ensureAclV1(acl: Acl): NormalizedAclV1 {
acl.acl_v1 ??= { chains: [], group: emptyGroupInfo() }
acl.acl_v1.chains ??= []
acl.acl_v1.chains.forEach(ensureAclChain)
acl.acl_v1.group = ensureGroupInfo(acl.acl_v1.group ?? emptyGroupInfo())
return acl.acl_v1 as NormalizedAclV1
}
function normalizeAcl(acl: Acl | undefined): Acl {
const source = acl ?? emptyAcl()
const aclV1 = source.acl_v1 ?? { chains: [], group: emptyGroupInfo() }
return {
...source,
acl_v1: {
...aclV1,
chains: (aclV1.chains ?? []).map((chain) => ({
...chain,
rules: (chain.rules ?? []).map((rule) => ({ ...ensureAclRuleLists({ ...rule }) })),
})),
group: ensureGroupInfo({
...(aclV1.group ?? emptyGroupInfo()),
declares: aclV1.group?.declares ?? [],
members: aclV1.group?.members ?? [],
}),
},
}
}
function isGroupInfoEmpty(group: GroupInfo | undefined): boolean {
return (group?.declares?.length ?? 0) === 0 && (group?.members?.length ?? 0) === 0
}
function isAclEmpty(acl: Acl | undefined): boolean {
const aclV1 = acl?.acl_v1
return !aclV1 || ((aclV1.chains?.length ?? 0) === 0 && isGroupInfoEmpty(aclV1.group))
}
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
if (v == null) return null
try {
const n = typeof v === 'bigint' ? v : BigInt(v)
if (n === 0n || n > UINT64_MAX) return null
return n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString()
} catch {
return null
}
}
function normalizeNumberForInput(v: number | string | null | undefined): number | null {
if (v == null) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function toBackendUint64(v: number | bigint | string | null | undefined): bigint | undefined {
if (v == null || v === '') return undefined
try {
const n = typeof v === 'bigint' ? v : BigInt(v)
return n > 0n && n <= UINT64_MAX ? n : undefined
} catch {
return undefined
}
}
function applyNetworkingMethod(
config: NetworkingConfigFields,
options: NetworkingMethodOptions = {},
): void {
const existingPeers = cleanNetworkPeers(config.peers)
config.peer_urls = cleanPeerUrls(config.peer_urls)
if (options.fillPeerUrlsFromPeers && config.peer_urls.length === 0 && existingPeers.length > 0) {
config.peer_urls = existingPeers.map((peer) => peer.uri)
}
const publicServerUrl = config.public_server_url?.trim() ?? ''
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
switch (networkingMethod) {
switch (normalized.networking_method) {
case NetworkingMethod.PublicServer:
config.peer_urls = publicServerUrl
? [publicServerUrl]
: (options.fillPeerUrlsFromPeers ? existingPeers.map((peer) => peer.uri) : [])
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
break
case NetworkingMethod.Manual:
break
case NetworkingMethod.Standalone:
default:
config.peer_urls = []
normalized.peer_urls = []
break
}
config.networking_method = NetworkingMethod.Manual
config.public_server_url = ''
config.peers = peersFromUrls(config.peer_urls, existingPeers)
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
ignoreUnknownFields: true,
}) as unknown as NetworkConfig
applyNetworkingMethod(normalized, { fillPeerUrlsFromPeers: true })
normalized.mtu = normalizeNumberForInput(normalized.mtu)
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
normalized.instance_recv_bps_limit as any,
)
normalized.proxy_cidrs ??= []
normalized.listener_urls ??= []
normalized.relay_network_whitelist ??= []
normalized.routes ??= []
normalized.exit_nodes ??= []
normalized.mapped_listeners ??= []
normalized.port_forwards ??= []
normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl)
normalized.networking_method = NetworkingMethod.Manual
normalized.public_server_url = ''
return normalized
}
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
const backend = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
ignoreUnknownFields: true,
})
applyNetworkingMethod(backend)
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
if (config.acl === undefined || isAclEmpty(config.acl)) {
backend.acl = undefined
}
return NetworkConfigPb.toJson(backend, {
useProtoFieldName: true,
}) as unknown as NetworkConfig
return normalizeNetworkConfig(config)
}
export interface NetworkInstance {
@@ -412,7 +354,6 @@ export interface Route {
proxy_cidrs: string[]
hostname: string
stun_info?: StunInfo
feature_flag?: PeerFeatureFlag
inst_id: string
version: string
}
@@ -420,7 +361,6 @@ export interface Route {
export interface PeerInfo {
peer_id: number
conns: PeerConnInfo[]
default_conn_id?: CommonUuid
}
export interface PeerConnInfo {
@@ -431,7 +371,7 @@ export interface PeerConnInfo {
features: string[]
tunnel?: TunnelInfo
stats?: PeerConnStats
loss_rate?: number | string
loss_rate: number
}
export interface PeerRoutePair {
@@ -450,18 +390,19 @@ export interface TunnelInfo {
}
export interface PeerConnStats {
rx_bytes: number | string
tx_bytes: number | string
rx_packets: number | string
tx_packets: number | string
latency_us: number | string
rx_bytes: number
tx_bytes: number
rx_packets: number
tx_packets: number
latency_us: number
}
export interface CommonUuid {
part1?: number
part2?: number
part3?: number
part4?: number
export interface PortForwardConfig {
bind_ip: string,
bind_port: number,
dst_ip: string,
dst_port: number,
proto: string
}
// 添加新行
@@ -1,85 +0,0 @@
import {
Action as AclAction,
ChainType as AclChainType,
Protocol as AclProtocol,
} from '../generated/proto/acl'
import type { NetworkConfig } from './network'
const UINT64_MAX = (1n << 64n) - 1n
type JsonRecord = Record<string, unknown>
export function prepareNetworkConfigForProtoJson(config: NetworkConfig): NetworkConfig {
const prepared = dropUnsupportedJsonValues(applyLegacyAclDefaults(config)) as NetworkConfig
normalizeLegacyOptionalUint64(prepared as JsonRecord, 'instance_recv_bps_limit')
return prepared
}
function applyLegacyAclDefaults(config: NetworkConfig): NetworkConfig {
const acl = config.acl
const aclV1 = acl?.acl_v1
if (!Array.isArray(aclV1?.chains)) return config
return {
...config,
acl: {
...acl,
acl_v1: {
...aclV1,
chains: aclV1.chains.map((chain) => ({
...chain,
chain_type: chain.chain_type ?? AclChainType.UnspecifiedChain,
default_action: chain.default_action ?? AclAction.Allow,
rules: (chain.rules ?? []).map((rule) => ({
...rule,
protocol: rule.protocol ?? AclProtocol.Any,
action: rule.action ?? AclAction.Allow,
})),
})),
},
},
}
}
function dropUnsupportedJsonValues(value: unknown): unknown {
if (value === undefined) return undefined
if (typeof value === 'number' && !Number.isFinite(value)) return undefined
if (Array.isArray(value)) {
return value.map(dropUnsupportedJsonValues).filter((v) => v !== undefined)
}
if (isJsonRecord(value)) {
return Object.fromEntries(
Object.entries(value)
.map(([k, v]) => [k, dropUnsupportedJsonValues(v)])
.filter(([, v]) => v !== undefined),
)
}
return value
}
function isJsonRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null
}
function normalizeLegacyOptionalUint64(obj: JsonRecord, key: string): void {
const value = obj[key]
if (typeof value !== 'string') return
const trimmed = value.trim()
if (!isPositiveUint64String(trimmed)) {
delete obj[key]
return
}
obj[key] = trimmed
}
function isPositiveUint64String(value: string): boolean {
if (!/^\d+$/.test(value)) return false
const n = BigInt(value)
return n > 0n && n <= UINT64_MAX
}
@@ -1,563 +0,0 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, reactive } from 'vue'
import Config from '../src/components/Config.vue'
import {
DEFAULT_NETWORK_CONFIG,
toBackendNetworkConfig,
type NetworkConfig,
} from '../src/types/network'
const CONFIG_FLAG_FIELDS = [
'latency_first',
'use_smoltcp',
'disable_ipv6',
'ipv6_public_addr_auto',
'enable_kcp_proxy',
'disable_kcp_input',
'enable_quic_proxy',
'disable_quic_input',
'disable_p2p',
'p2p_only',
'lazy_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'need_p2p',
'multi_thread',
'proxy_forward_by_system',
'disable_encryption',
'disable_tcp_hole_punching',
'disable_udp_hole_punching',
'enable_udp_broadcast_relay',
'disable_upnp',
'disable_sym_hole_punching',
'enable_magic_dns',
'enable_private_mode',
] as const satisfies readonly (keyof NetworkConfig)[]
const CONFIG_CHECKBOX_FIELDS = [
['dhcp', '#virtual_ip_auto'],
...CONFIG_FLAG_FIELDS.map((field) => [field, `#${field}`] as const),
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
const CONFIG_TOGGLE_FIELDS = [
'enable_vpn_portal',
'enable_relay_network_whitelist',
'enable_manual_routes',
'enable_socks5',
] as const satisfies readonly (keyof NetworkConfig)[]
const CONFIG_UI_BOOLEAN_FIELDS = [
...CONFIG_CHECKBOX_FIELDS.map(([field]) => field),
...CONFIG_TOGGLE_FIELDS,
] as const satisfies readonly (keyof NetworkConfig)[]
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, values?: unknown[]) => values ? `${key}:${values.join(',')}` : key,
}),
}))
const PassThrough = defineComponent({
name: 'PassThrough',
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
const PanelStub = defineComponent({
name: 'Panel',
props: {
header: String,
},
setup(props, { slots }) {
return () => h('section', { 'data-stub': 'panel', 'data-header': props.header }, slots.default?.())
},
})
const DividerStub = defineComponent({
name: 'Divider',
setup() {
return () => h('hr', { 'data-stub': 'divider' })
},
})
function splitList(value: string): string[] {
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
}
const InputTextStub = defineComponent({
name: 'InputText',
props: {
modelValue: [String, Number],
id: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
disabled: props.disabled,
value: props.modelValue ?? '',
'data-stub': 'input-text',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
const PasswordStub = defineComponent({
name: 'Password',
props: {
modelValue: [String, Number],
id: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
disabled: props.disabled,
type: 'password',
value: props.modelValue ?? '',
'data-stub': 'password',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
const InputNumberStub = defineComponent({
name: 'InputNumber',
props: {
modelValue: Number,
id: String,
inputId: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id ?? props.inputId,
disabled: props.disabled,
type: 'number',
value: props.modelValue ?? '',
'data-stub': 'input-number',
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? null : Number(value))
},
})
},
})
const CheckboxStub = defineComponent({
name: 'Checkbox',
props: {
modelValue: Boolean,
inputId: String,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.inputId,
checked: props.modelValue,
type: 'checkbox',
'data-stub': 'checkbox',
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
})
},
})
const ToggleButtonStub = defineComponent({
name: 'ToggleButton',
props: {
modelValue: Boolean,
onIcon: String,
offIcon: String,
onLabel: String,
offLabel: String,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('button', {
type: 'button',
'aria-pressed': String(Boolean(props.modelValue)),
'data-stub': 'toggle-button',
onClick: () => emit('update:modelValue', !props.modelValue),
}, props.modelValue ? props.onLabel : props.offLabel)
},
})
const AutoCompleteStub = defineComponent({
name: 'AutoComplete',
props: {
modelValue: Array,
id: String,
multiple: Boolean,
},
emits: ['update:modelValue', 'complete'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
value: (props.modelValue ?? []).join(','),
'data-stub': 'auto-complete',
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
})
},
})
const UrlListInputStub = defineComponent({
name: 'UrlListInput',
props: {
modelValue: Array,
id: String,
addLabel: String,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
value: (props.modelValue ?? []).join(','),
'data-stub': 'url-list-input',
'data-add-label': props.addLabel,
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
})
},
})
const SelectButtonStub = defineComponent({
name: 'SelectButton',
props: {
modelValue: String,
options: Array,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('select', {
value: props.modelValue,
'data-stub': 'select-button',
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
}, (props.options ?? []).map((option) => h('option', { value: option as string }, option as string)))
},
})
const ButtonStub = defineComponent({
name: 'Button',
props: {
label: String,
icon: String,
disabled: Boolean,
},
emits: ['click'],
setup(props, { slots, emit }) {
return () => h('button', {
type: 'button',
disabled: props.disabled,
'data-label': props.label ?? props.icon,
onClick: (event: MouseEvent) => emit('click', event),
}, slots.default?.() ?? props.label ?? props.icon)
},
})
const DialogStub = defineComponent({
name: 'Dialog',
props: {
visible: Boolean,
},
setup(props, { slots }) {
return () => h('div', { hidden: !props.visible, 'data-stub': 'dialog' }, [
slots.default?.(),
slots.footer?.(),
])
},
})
const AclManagerStub = defineComponent({
name: 'AclManager',
props: {
modelValue: Object,
},
emits: ['update:modelValue'],
setup(props) {
return () => h('pre', { 'data-stub': 'acl-manager' }, JSON.stringify(props.modelValue))
},
})
function makeConfig(): NetworkConfig {
const config = DEFAULT_NETWORK_CONFIG()
return {
...config,
dhcp: false,
virtual_ipv4: '10.1.2.3',
network_length: 24,
network_name: 'mesh-a',
network_secret: 'secret-a',
peer_urls: ['tcp://peer-a:11010', 'udp://peer-b:11010'],
latency_first: true,
use_smoltcp: true,
disable_ipv6: true,
no_tun: true,
hostname: 'host-a',
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
enable_vpn_portal: true,
vpn_portal_client_network_addr: '10.144.0.0',
vpn_portal_listen_port: 22023,
listener_urls: ['tcp://0.0.0.0:12010'],
dev_name: 'tun-test',
mtu: 1280,
instance_recv_bps_limit: '9007199254740993',
enable_relay_network_whitelist: true,
relay_network_whitelist: ['network-a'],
enable_manual_routes: true,
routes: ['192.168.0.0/16'],
enable_socks5: true,
socks5_port: 1086,
exit_nodes: ['exit-a'],
mapped_listeners: ['tcp://127.0.0.1:22000'],
port_forwards: [{
proto: 'udp',
bind_ip: '0.0.0.0',
bind_port: 18080,
dst_ip: '10.0.0.2',
dst_port: 8080,
}],
}
}
function mountConfig(config: NetworkConfig = makeConfig()) {
const curNetwork = reactive(config) as NetworkConfig
const wrapper = mount(Config, {
props: {
curNetwork,
hostname: 'host-from-prop',
},
global: {
directives: {
tooltip: () => {},
},
stubs: {
AclManager: AclManagerStub,
AutoComplete: AutoCompleteStub,
Button: ButtonStub,
Checkbox: CheckboxStub,
Dialog: DialogStub,
Divider: DividerStub,
InputGroup: PassThrough,
InputGroupAddon: PassThrough,
InputNumber: InputNumberStub,
InputText: InputTextStub,
Panel: PanelStub,
Password: PasswordStub,
SelectButton: SelectButtonStub,
ToggleButton: ToggleButtonStub,
UrlListInput: UrlListInputStub,
},
},
})
return { curNetwork, wrapper }
}
function input(wrapper: VueWrapper, selector: string): HTMLInputElement {
return wrapper.find(selector).element as HTMLInputElement
}
async function setInput(wrapper: VueWrapper, selector: string, value: string) {
await wrapper.find(selector).setValue(value)
await nextTick()
}
describe('Config.vue network config projection', () => {
it('projects config values into the visible form controls', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
expect(input(wrapper, '#network_name').value).toBe('mesh-a')
expect(input(wrapper, '#network_secret').value).toBe('secret-a')
expect(input(wrapper, '#virtual_ip').value).toBe('10.1.2.3')
expect(input(wrapper, '#initial_nodes').value).toBe('tcp://peer-a:11010,udp://peer-b:11010')
expect(input(wrapper, '#virtual_ip_auto').checked).toBe(false)
expect(input(wrapper, '#latency_first').checked).toBe(true)
expect(input(wrapper, '#use_smoltcp').checked).toBe(true)
expect(input(wrapper, '#disable_ipv6').checked).toBe(true)
expect(input(wrapper, '#no_tun').checked).toBe(true)
expect(input(wrapper, '#hostname').value).toBe('host-a')
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
expect(input(wrapper, '#mtu').value).toBe('1280')
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
expect(input(wrapper, '#relay_network_whitelist').value).toBe('network-a')
expect(input(wrapper, '#routes').value).toBe('192.168.0.0/16')
expect(input(wrapper, '#socks5_port').value).toBe('1086')
expect(input(wrapper, '#exit_nodes').value).toBe('exit-a')
expect(input(wrapper, 'input[data-add-label="add_listener_url"]').value).toBe('tcp://0.0.0.0:12010')
expect(input(wrapper, 'input[data-add-label="add_mapped_listener"]').value).toBe('tcp://127.0.0.1:22000')
expect(wrapper.find<HTMLSelectElement>('select[data-stub="select-button"]').element.value).toBe('udp')
expect(input(wrapper, 'input[placeholder="port_forwards_bind_addr"]').value).toBe('0.0.0.0')
expect(input(wrapper, 'input[placeholder="port_forwards_dst_addr"]').value).toBe('10.0.0.2')
expect(wrapper.findComponent(AclManagerStub).props('modelValue')).toStrictEqual(curNetwork.acl)
})
it('projects form edits back into config and backend JSON', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await wrapper.find('#virtual_ip_auto').setValue(false)
await setInput(wrapper, '#network_name', 'mesh-edited')
await setInput(wrapper, '#network_secret', 'secret-edited')
await setInput(wrapper, '#virtual_ip', '10.7.7.7')
await setInput(wrapper, '#initial_nodes', ' tcp://peer-x:11010, , udp://peer-y:11010 ')
await wrapper.find('#no_tun').setValue(false)
await wrapper.find('#disable_ipv6').setValue(false)
await setInput(wrapper, '#hostname', 'host-edited')
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
await setInput(wrapper, '#dev_name', 'tun-edited')
await setInput(wrapper, '#mtu', '1260')
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
await setInput(wrapper, '#relay_network_whitelist', 'network-edited')
await setInput(wrapper, '#routes', '192.168.10.0/24')
await setInput(wrapper, '#socks5_port', '1089')
await setInput(wrapper, '#exit_nodes', 'exit-edited')
await setInput(wrapper, 'input[data-add-label="add_mapped_listener"]', 'tcp://127.0.0.1:23000')
await wrapper.find('select[data-stub="select-button"]').setValue('tcp')
await setInput(wrapper, 'input[placeholder="port_forwards_bind_addr"]', '127.0.0.1')
await setInput(wrapper, 'input[placeholder="port_forwards_dst_addr"]', '10.9.0.2')
const portNumbers = wrapper.findAll<HTMLInputElement>('input#horizontal-buttons')
await portNumbers[1].setValue('19090')
await portNumbers[2].setValue('9090')
expect(curNetwork).toMatchObject({
dhcp: false,
virtual_ipv4: '10.7.7.7',
network_name: 'mesh-edited',
network_secret: 'secret-edited',
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
no_tun: false,
disable_ipv6: false,
hostname: 'host-edited',
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
vpn_portal_client_network_addr: '10.200.0.0',
listener_urls: ['tcp://0.0.0.0:13010'],
dev_name: 'tun-edited',
mtu: 1260,
instance_recv_bps_limit: '9007199254740993',
relay_network_whitelist: ['network-edited'],
routes: ['192.168.10.0/24'],
socks5_port: 1089,
exit_nodes: ['exit-edited'],
mapped_listeners: ['tcp://127.0.0.1:23000'],
port_forwards: [{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 19090,
dst_ip: '10.9.0.2',
dst_port: 9090,
}],
})
const backend = toBackendNetworkConfig(curNetwork)
expect(backend).toMatchObject({
virtual_ipv4: '10.7.7.7',
network_name: 'mesh-edited',
network_secret: 'secret-edited',
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
listener_urls: ['tcp://0.0.0.0:13010'],
mtu: 1260,
instance_recv_bps_limit: '9007199254740993',
port_forwards: [{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 19090,
dst_ip: '10.9.0.2',
dst_port: 9090,
}],
})
})
it('round-trips every visible boolean config control into backend JSON', async () => {
const config = makeConfig()
const originalFlagValues = new Map(
CONFIG_UI_BOOLEAN_FIELDS.map((field, index) => {
const value = index % 2 === 0
config[field] = value
return [field, value]
}),
)
const { curNetwork, wrapper } = mountConfig(config)
await nextTick()
for (const [field, selector] of CONFIG_CHECKBOX_FIELDS) {
const value = originalFlagValues.get(field)
expect(input(wrapper, selector).checked, `${field} should project into UI`).toBe(value)
await wrapper.find(selector).setValue(!value)
await nextTick()
}
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
const value = originalFlagValues.get(field)
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
.toBe(String(value))
await toggleButtons[index].trigger('click')
await nextTick()
}
const backend = toBackendNetworkConfig(curNetwork) as Record<string, unknown>
for (const [field, value] of originalFlagValues) {
const expectedValue = !value
expect(curNetwork[field], `${field} should update config`).toBe(expectedValue)
expect(backend[field], `${field} should be preserved in backend JSON`).toBe(expectedValue)
}
})
it('keeps uint64 input editable without losing large values', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await setInput(wrapper, '#instance_recv_bps_limit', '1234')
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
await setInput(wrapper, '#instance_recv_bps_limit', 'not-a-number')
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
await setInput(wrapper, '#instance_recv_bps_limit', '0')
expect(curNetwork.instance_recv_bps_limit).toBeNull()
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('')
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
await setInput(wrapper, '#instance_recv_bps_limit', '18446744073709551616')
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
await setInput(wrapper, '#instance_recv_bps_limit', '')
expect(curNetwork.instance_recv_bps_limit).toBeNull()
})
it('emits runNetwork with the current projected config', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await setInput(wrapper, '#network_name', 'mesh-running')
await wrapper.find('button[data-label="run_network"]').trigger('click')
expect(wrapper.emitted('runNetwork')?.[0]).toEqual([curNetwork])
expect((wrapper.emitted('runNetwork')?.[0][0] as NetworkConfig).network_name).toBe('mesh-running')
})
})
@@ -1,228 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import RemoteManagement from '../src/components/RemoteManagement.vue'
import {
DEFAULT_NETWORK_CONFIG,
type NetworkConfig,
} from '../src/types/network'
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
'disable_ipv6',
'enable_kcp_proxy',
'disable_kcp_input',
'disable_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'multi_thread',
'enable_relay_network_whitelist',
'enable_manual_routes',
'proxy_forward_by_system',
'disable_encryption',
'enable_socks5',
'disable_udp_hole_punching',
'enable_magic_dns',
'enable_private_mode',
'enable_quic_proxy',
'disable_quic_input',
'disable_sym_hole_punching',
'p2p_only',
'lazy_p2p',
'need_p2p',
'disable_upnp',
'ipv6_public_addr_provider',
'ipv6_public_addr_auto',
'disable_relay_data',
'enable_udp_broadcast_relay',
'disable_tcp_hole_punching',
] as const satisfies readonly (keyof NetworkConfig)[]
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('primevue', async () => {
const { defineComponent, h } = await import('vue')
const PassThrough = defineComponent({
name: 'PassThrough',
props: {
label: String,
value: String,
},
setup(props, { slots }) {
return () => h('div', {
'data-label': props.label,
'data-value': props.value,
'data-stub': 'pass-through',
}, slots.default?.())
},
})
const ButtonStub = defineComponent({
name: 'Button',
props: {
label: String,
icon: String,
disabled: Boolean,
},
emits: ['click'],
setup(props, { slots, emit }) {
return () => h('button', {
type: 'button',
disabled: props.disabled,
'data-label': props.label ?? props.icon,
onClick: (event: MouseEvent) => emit('click', event),
}, slots.default?.() ?? props.label ?? props.icon)
},
})
const SelectStub = defineComponent({
name: 'Select',
props: {
modelValue: Object,
options: Array,
},
emits: ['update:modelValue'],
setup(props, { slots }) {
return () => h('div', { 'data-stub': 'select' }, [
slots.value?.({ value: props.modelValue, placeholder: '' }),
])
},
})
const MenuStub = defineComponent({
name: 'Menu',
setup(_, { expose }) {
expose({ toggle: vi.fn() })
return () => h('div', { 'data-stub': 'menu' })
},
})
return {
Button: ButtonStub,
ConfirmPopup: PassThrough,
Divider: PassThrough,
IftaLabel: PassThrough,
Menu: MenuStub,
Message: PassThrough,
Select: SelectStub,
Tag: PassThrough,
useConfirm: () => ({ require: vi.fn() }),
useToast: () => ({ add: vi.fn() }),
}
})
const INSTANCE_ID = '00000000-0000-0000-0000-000000000001'
const INSTANCE_UUID = {
part1: 0,
part2: 0,
part3: 0,
part4: 1,
}
function makeFlagConfig(): NetworkConfig {
const config = {
...DEFAULT_NETWORK_CONFIG(),
instance_id: INSTANCE_ID,
network_name: 'mesh-save',
}
BOOLEAN_CONFIG_FIELDS.forEach((field, index) => {
config[field] = index % 2 === 0
})
return config
}
function cloneConfig(config: NetworkConfig): NetworkConfig {
return JSON.parse(JSON.stringify(config)) as NetworkConfig
}
function snapshotBooleanConfigFields(config: NetworkConfig): Record<string, unknown> {
return Object.fromEntries(
BOOLEAN_CONFIG_FIELDS.map((field) => [field, config[field]]),
)
}
async function settleRemoteManagement() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
await nextTick()
}
}
describe('RemoteManagement config save', () => {
it('saves the current network config without dropping boolean fields', async () => {
const config = makeFlagConfig()
const expectedFlags = snapshotBooleanConfigFields(config)
const api = {
delete_network: vi.fn(),
generate_config: vi.fn(),
get_network_config: vi.fn(async () => cloneConfig(config)),
get_network_info: vi.fn(),
get_network_metas: vi.fn(async (instanceIds: string[]) => ({
metas: Object.fromEntries(instanceIds.map((id) => [id, {
config_permission: 0xffffffff,
inst_id: INSTANCE_UUID,
instance_name: 'mesh-save',
network_name: 'mesh-save',
source: 2,
}])),
})),
list_network_instance_ids: vi.fn(async () => ({
disabled_inst_ids: [INSTANCE_UUID],
running_inst_ids: [],
})),
parse_config: vi.fn(),
run_network: vi.fn(),
save_config: vi.fn(async () => undefined),
update_network_instance_state: vi.fn(),
validate_config: vi.fn(),
}
const wrapper = mount(RemoteManagement, {
props: {
api,
instanceId: INSTANCE_ID,
},
global: {
stubs: {
Config: true,
ConfigEditDialog: true,
Status: true,
},
},
})
try {
await settleRemoteManagement()
const saveButton = wrapper.find('button[data-label="web.device_management.save_config"]')
expect(saveButton.exists()).toBe(true)
expect(saveButton.attributes('disabled')).toBeUndefined()
await saveButton.trigger('click')
await flushPromises()
expect(api.save_config).toHaveBeenCalledOnce()
const savedConfig = api.save_config.mock.calls[0][0] as NetworkConfig
for (const field of BOOLEAN_CONFIG_FIELDS) {
expect(savedConfig[field], `${field} should be saved`).toBe(expectedFlags[field])
}
} finally {
wrapper.unmount()
}
})
})
-9
View File
@@ -1,9 +0,0 @@
import { vi } from 'vitest'
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import { latencyMs, lossRate } from '../src/modules/statusDisplay'
import { ipv4ToString, ipv6ToString } from '../src/modules/utils'
function peerRoutePair(conns: any[]) {
return {
route: {
ipv4_addr: '10.0.0.2',
hostname: 'peer',
version: 'test',
},
peer: {
conns,
},
} as any
}
function peerRoutePairWithDefaultConn(conns: any[], defaultConnId: string) {
const [part1, part2, part3, part4] = defaultConnId
.replaceAll('-', '')
.match(/.{8}/g)!
.map((part) => Number.parseInt(part, 16))
return {
...peerRoutePair(conns),
peer: {
default_conn_id: {
part1,
part2,
part3,
part4,
},
conns,
},
} as any
}
describe('status display helpers', () => {
it('does not render missing IP values as zero addresses', () => {
expect(ipv4ToString(undefined)).toBe('')
expect(ipv4ToString(null)).toBe('')
expect(ipv4ToString({} as any)).toBe('0.0.0.0')
expect(ipv4ToString({ addr: 0 })).toBe('0.0.0.0')
expect(ipv6ToString(undefined)).toBe('')
expect(ipv6ToString(null)).toBe('')
expect(ipv6ToString({} as any)).toBe('::0')
expect(ipv6ToString({ part1: 0, part2: 0, part3: 0, part4: 0 })).toBe('::0')
expect(ipv6ToString({ part4: 1 } as any)).toBe('::1')
})
it('skips missing latency and loss values', () => {
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'valid', stats: { latency_us: '2500' } },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('3ms')
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'valid', loss_rate: '0.25' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('25%')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('')
})
it('prefers the default connection when its metric is valid', () => {
const defaultConnId = '00000001-0002-0003-0004-000000000005'
const conns = [
{ conn_id: 'fallback', stats: { latency_us: '1000' }, loss_rate: '0.01' },
{ conn_id: defaultConnId, stats: { latency_us: '9000' }, loss_rate: '0.5' },
]
expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms')
expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%')
})
})
@@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import ViteYaml from '@modyfi/vite-plugin-yaml'
export default defineConfig({
plugins: [vue(), ViteYaml()],
test: {
environment: 'happy-dom',
include: ['tests/**/*.spec.ts'],
setupFiles: ['./tests/setup.ts'],
},
})
+3 -3
View File
@@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "pnpm --dir ../frontend-lib build && vite",
"build": "pnpm --dir ../frontend-lib build && vue-tsc -b && vite build",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
@@ -32,4 +32,4 @@
"vite-plugin-singlefile": "^2.0.3",
"vue-tsc": "^2.1.10"
}
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ class WebRemoteClient implements Api.RemoteClient {
}
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id);
return response.info?.map?.[inst_id];
return response.info.map[inst_id];
}
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
-3
View File
@@ -40,9 +40,6 @@ cli:
geoip_db:
en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb"
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
heartbeat_min_response_ms:
en: "Minimum response time for config-server heartbeat RPCs in milliseconds, default is 0"
zh-CN: "配置服务心跳 RPC 的最短响应时间,单位毫秒,默认为 0"
disable_registration:
en: "Disable user registration"
zh-CN: "禁用用户注册"
File diff suppressed because it is too large Load Diff
+18 -703
View File
@@ -1,5 +1,3 @@
mod managed_config;
mod runtime_reconcile;
pub mod session;
pub mod storage;
@@ -7,7 +5,6 @@ use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
use std::time::Duration;
use dashmap::DashMap;
use easytier::{
@@ -23,7 +20,7 @@ use session::{Location, Session};
use storage::{Storage, StorageToken};
use crate::FeatureFlags;
use crate::webhook::{ManagedNetworkConfig, SharedWebhookConfig};
use crate::webhook::SharedWebhookConfig;
use tokio::task::JoinSet;
use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
@@ -33,10 +30,6 @@ use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
#[include = "geoip2-cn.mmdb"]
struct GeoipDb;
pub fn is_managed_config_revision_conflict(error: &anyhow::Error) -> bool {
managed_config::is_revision_conflict(error)
}
fn load_geoip_db(geoip_db: Option<String>) -> Option<maxminddb::Reader<Vec<u8>>> {
if let Some(path) = geoip_db {
match maxminddb::Reader::open_readfile(&path) {
@@ -70,14 +63,12 @@ pub struct ClientManager {
webhook_config: SharedWebhookConfig,
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
heartbeat_min_response_delay: Duration,
}
impl ClientManager {
pub fn new(
db: Db,
geoip_db: Option<String>,
heartbeat_min_response_delay: Duration,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
) -> Self {
@@ -101,7 +92,6 @@ impl ClientManager {
webhook_config,
geoip_db: Arc::new(load_geoip_db(geoip_db)),
heartbeat_min_response_delay,
}
}
@@ -115,7 +105,6 @@ impl ClientManager {
let storage = self.storage.weak_ref();
let listeners_cnt = self.listeners_cnt.clone();
let geoip_db = self.geoip_db.clone();
let heartbeat_min_response_delay = self.heartbeat_min_response_delay;
let feature_flags = self.feature_flags.clone();
let webhook_config = self.webhook_config.clone();
self.tasks.spawn(async move {
@@ -140,7 +129,6 @@ impl ClientManager {
storage.clone(),
client_url.clone(),
location,
heartbeat_min_response_delay,
feature_flags.clone(),
webhook_config.clone(),
);
@@ -158,11 +146,20 @@ impl ClientManager {
}
pub async fn list_sessions(&self) -> Vec<StorageToken> {
self.storage.list_clients()
}
let sessions = self
.client_sessions
.iter()
.map(|item| item.value().clone())
.collect::<Vec<_>>();
pub async fn list_all_sessions(&self) -> Vec<StorageToken> {
self.storage.list_all_clients()
let mut ret: Vec<StorageToken> = vec![];
for s in sessions {
if let Some(t) = s.get_token().await {
ret.push(t);
}
}
ret
}
pub fn get_session_by_machine_id(
@@ -185,7 +182,7 @@ impl ClientManager {
) -> bool {
let Some(client_url) = self
.storage
.get_client_url_by_machine_id_with_auth(user_id, machine_id, false)
.get_client_url_by_machine_id(user_id, machine_id)
else {
return false;
};
@@ -200,38 +197,6 @@ impl ClientManager {
self.storage.list_user_clients(user_id)
}
pub async fn reconcile_managed_network_configs(
&self,
user_id: UserIdInDb,
machine_id: uuid::Uuid,
desired_configs: Vec<ManagedNetworkConfig>,
config_revision: Option<String>,
expected_config_revision: Option<String>,
) -> anyhow::Result<()> {
let expected_config_revision = match expected_config_revision.as_deref().map(str::trim) {
None => managed_config::ExpectedConfigRevision::Any,
Some("") => managed_config::ExpectedConfigRevision::Exact(None),
Some(revision) => managed_config::ExpectedConfigRevision::Exact(Some(revision)),
};
managed_config::reconcile_web_source_configs(
&self.storage,
user_id,
machine_id,
desired_configs,
config_revision.as_deref(),
expected_config_revision,
)
.await?;
if let Some(config_revision) = config_revision
&& let Some(session) = self.get_session_by_machine_id(user_id, &machine_id)
{
session
.notify_config_revision_changed(user_id, machine_id, config_revision)
.await;
}
Ok(())
}
pub async fn get_heartbeat_requests(&self, client_url: &url::Url) -> Option<HeartbeatRequest> {
let s = self.client_sessions.get(client_url)?.clone();
s.data().read().await.req()
@@ -363,449 +328,19 @@ impl
#[cfg(test)]
mod tests {
use std::{
collections::VecDeque,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration,
};
use std::{sync::Arc, time::Duration};
use axum::{Json, Router, extract::State, routing::post};
use easytier::{
common::MachineIdOptions,
instance_manager::NetworkInstanceManager,
proto::{
api::manage::{NetworkConfig, NetworkingMethod, PortForwardConfig},
common::CompressionAlgoPb,
},
rpc_service::remote_client::Storage as RemoteStorage,
tunnel::{
common::tests::wait_for_condition,
udp::{UdpTunnelConnector, UdpTunnelListener},
},
web_client::{WebClient, run_web_client},
web_client::WebClient,
};
use serde_json::json;
use sqlx::Executor;
use tokio::net::UdpSocket;
use crate::{
FeatureFlags, client_manager::ClientManager, db::Db, webhook::ManagedNetworkConfig,
};
const MANAGED_CONFIG_TOKEN: &str = "managed-config-token";
#[derive(Debug, Clone)]
struct TestWebhookState {
validate_responses: Arc<tokio::sync::Mutex<VecDeque<bool>>>,
validate_count: Arc<AtomicUsize>,
block_second_validate: Arc<AtomicBool>,
allow_second_validate: Arc<AtomicBool>,
}
impl TestWebhookState {
fn new(validate_responses: impl IntoIterator<Item = bool>) -> Self {
Self {
validate_responses: Arc::new(tokio::sync::Mutex::new(
validate_responses.into_iter().collect(),
)),
validate_count: Arc::new(AtomicUsize::new(0)),
block_second_validate: Arc::new(AtomicBool::new(false)),
allow_second_validate: Arc::new(AtomicBool::new(true)),
}
}
fn with_blocked_second_validate(
validate_responses: impl IntoIterator<Item = bool>,
) -> Self {
let state = Self::new(validate_responses);
state.block_second_validate.store(true, Ordering::Release);
state.allow_second_validate.store(false, Ordering::Release);
state
}
fn allow_second_validate(&self) {
self.allow_second_validate.store(true, Ordering::Release);
}
fn validate_count(&self) -> usize {
self.validate_count.load(Ordering::Acquire)
}
}
async fn validate_token_handler(
State(state): State<TestWebhookState>,
) -> Json<serde_json::Value> {
let count = state.validate_count.fetch_add(1, Ordering::AcqRel) + 1;
if count == 2 && state.block_second_validate.load(Ordering::Acquire) {
while !state.allow_second_validate.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
let valid = state
.validate_responses
.lock()
.await
.pop_front()
.unwrap_or(true);
if !valid {
return Json(json!({ "valid": false }));
}
Json(json!({
"valid": true,
"binding_version": count,
"config_revision": format!("validated-rev-{count}")
}))
}
async fn webhook_ack_handler() -> Json<serde_json::Value> {
Json(json!({}))
}
async fn test_webhook_config() -> (
crate::webhook::SharedWebhookConfig,
tokio::task::JoinHandle<()>,
TestWebhookState,
) {
let state = TestWebhookState::new([true]);
test_webhook_config_with_state(state).await
}
async fn test_webhook_config_with_state(
state: TestWebhookState,
) -> (
crate::webhook::SharedWebhookConfig,
tokio::task::JoinHandle<()>,
TestWebhookState,
) {
let app = Router::new()
.route("/validate-token", post(validate_token_handler))
.route("/webhook/node-connected", post(webhook_ack_handler))
.route("/webhook/node-disconnected", post(webhook_ack_handler))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(
Arc::new(crate::webhook::WebhookConfig::new(
Some(format!("http://{addr}")),
None,
None,
None,
None,
)),
server,
state,
)
}
async fn add_random_udp_listener(mgr: &mut ClientManager) -> std::net::SocketAddr {
let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let addr = socket.local_addr().unwrap();
let listener =
UdpTunnelListener::new_with_socket(format!("udp://{addr}").parse().unwrap(), socket);
mgr.add_listener(listener).await.unwrap();
addr
}
async fn wait_for_validated_user(mgr: &ClientManager, machine_id: uuid::Uuid) -> i32 {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if let Some(token) = mgr.list_sessions().await.into_iter().find(|token| {
token.token == MANAGED_CONFIG_TOKEN && token.machine_id == machine_id
}) {
break token.user_id;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
async fn wait_for_validate_count(state: &TestWebhookState, target: usize) {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if state.validate_count() >= target {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.unwrap();
}
async fn wait_for_session_urls(mgr: &ClientManager) -> Vec<url::Url> {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
let urls = mgr
.client_sessions
.iter()
.map(|entry| entry.key().clone())
.collect::<Vec<_>>();
if !urls.is_empty() {
break urls;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
fn managed_config(
instance_id: uuid::Uuid,
network_config: serde_json::Value,
) -> ManagedNetworkConfig {
ManagedNetworkConfig {
instance_id: instance_id.to_string(),
network_config,
}
}
async fn wait_for_runtime_config(
manager: &NetworkInstanceManager,
inst_id: uuid::Uuid,
predicate: impl Fn(&NetworkConfig) -> bool,
) -> NetworkConfig {
tokio::time::timeout(Duration::from_secs(12), async {
loop {
if let Some(config) = manager
.get_instance_config(&inst_id)
.and_then(|config| NetworkConfig::new_from_config(&config).ok())
.filter(|config| predicate(config))
{
break config;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap()
}
async fn start_web_client_for_test(
config_server_addr: std::net::SocketAddr,
machine_id: uuid::Uuid,
manager: Arc<NetworkInstanceManager>,
) -> WebClient {
run_web_client(
&format!("udp://{config_server_addr}/{MANAGED_CONFIG_TOKEN}"),
MachineIdOptions {
explicit_machine_id: Some(machine_id.to_string()),
state_dir: None,
},
Some("managed-config-core".to_string()),
false,
manager,
None,
)
.await
.unwrap()
}
async fn clear_managed_config_db(
mgr: &ClientManager,
user_id: i32,
machine_id: uuid::Uuid,
instance_id: uuid::Uuid,
) {
mgr.db()
.delete_web_network_configs((user_id, machine_id), &[instance_id])
.await
.unwrap();
sqlx::query("DELETE FROM managed_config_revisions WHERE user_id = ? AND device_id = ?")
.bind(user_id)
.bind(machine_id.to_string())
.execute(&mgr.db().inner())
.await
.unwrap();
}
fn assert_updated_runtime_config(updated: &NetworkConfig, instance_id: uuid::Uuid) {
assert_eq!(
updated.instance_id.as_deref(),
Some(instance_id.to_string().as_str())
);
assert_eq!(updated.dhcp, Some(false));
assert_eq!(updated.virtual_ipv4.as_deref(), Some("10.88.0.7"));
assert_eq!(updated.network_length, Some(24));
assert_eq!(updated.hostname.as_deref(), Some("managed-updated-host"));
assert_eq!(updated.network_name.as_deref(), Some("managed-updated"));
assert_eq!(updated.network_secret.as_deref(), Some("secret-updated"));
assert_eq!(
updated.networking_method,
Some(NetworkingMethod::Manual as i32)
);
assert_eq!(updated.peer_urls, vec!["tcp://127.0.0.1:11010".to_string()]);
assert_eq!(
updated.proxy_cidrs,
vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string()
]
);
assert_eq!(updated.no_tun, Some(true));
assert_eq!(updated.disable_ipv6, Some(true));
assert_eq!(updated.enable_kcp_proxy, Some(true));
assert_eq!(updated.disable_kcp_input, Some(true));
assert_eq!(updated.enable_quic_proxy, Some(true));
assert_eq!(updated.disable_quic_input, Some(true));
assert_eq!(updated.disable_p2p, Some(true));
assert_eq!(updated.p2p_only, Some(true));
assert_eq!(updated.lazy_p2p, Some(true));
assert_eq!(updated.relay_all_peer_rpc, Some(true));
assert_eq!(updated.need_p2p, Some(true));
assert_eq!(updated.multi_thread, Some(false));
assert_eq!(updated.proxy_forward_by_system, Some(true));
assert_eq!(updated.disable_encryption, Some(true));
assert_eq!(updated.enable_relay_network_whitelist, Some(true));
assert_eq!(
updated.relay_network_whitelist,
vec!["10.44.0.0/24".to_string(), "10.45.0.0/24".to_string()]
);
assert_eq!(updated.enable_manual_routes, Some(true));
assert_eq!(
updated.routes,
vec!["10.60.0.0/16".to_string(), "10.61.0.0/16".to_string()]
);
assert_eq!(updated.port_forwards[0].bind_ip, "127.0.0.1");
assert_eq!(updated.port_forwards[0].bind_port, 0);
assert_eq!(updated.port_forwards[0].dst_ip, "10.88.0.8");
assert_eq!(updated.port_forwards[0].dst_port, 80);
assert_eq!(updated.port_forwards[0].proto, "tcp");
assert_eq!(updated.disable_udp_hole_punching, Some(true));
assert_eq!(updated.disable_tcp_hole_punching, Some(true));
assert_eq!(updated.disable_sym_hole_punching, Some(true));
assert_eq!(updated.disable_upnp, Some(true));
assert_eq!(updated.disable_relay_data, Some(true));
assert_eq!(updated.enable_magic_dns, Some(true));
assert_eq!(updated.enable_private_mode, Some(true));
assert_eq!(updated.mtu, Some(1360));
assert_eq!(
updated.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
assert_eq!(updated.encryption_algorithm.as_deref(), Some("xor"));
assert_eq!(updated.instance_recv_bps_limit, Some(123456));
assert_eq!(updated.enable_udp_broadcast_relay, Some(true));
assert_eq!(updated.socket_mark, Some(0));
}
fn initial_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
json!({
"instance_id": inst_id.to_string(),
"dhcp": true,
"network_name": "managed-initial",
"network_secret": "secret-initial",
"networking_method": "Standalone",
"no_tun": true,
"disable_ipv6": true,
"enable_kcp_proxy": false,
"disable_kcp_input": false,
"relay_all_peer_rpc": false,
"multi_thread": false,
"disable_relay_data": false,
"mtu": 1380
})
}
fn updated_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
serde_json::to_value(NetworkConfig {
instance_id: Some(inst_id.to_string()),
dhcp: Some(false),
virtual_ipv4: Some("10.88.0.7".to_string()),
network_length: Some(24),
hostname: Some("managed-updated-host".to_string()),
network_name: Some("managed-updated".to_string()),
network_secret: Some("secret-updated".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
peer_urls: vec!["tcp://127.0.0.1:11010".to_string()],
proxy_cidrs: vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string(),
],
no_tun: Some(true),
disable_ipv6: Some(true),
enable_kcp_proxy: Some(true),
disable_kcp_input: Some(true),
enable_quic_proxy: Some(true),
disable_quic_input: Some(true),
disable_p2p: Some(true),
p2p_only: Some(true),
lazy_p2p: Some(true),
relay_all_peer_rpc: Some(true),
need_p2p: Some(true),
multi_thread: Some(false),
proxy_forward_by_system: Some(true),
disable_encryption: Some(true),
enable_relay_network_whitelist: Some(true),
relay_network_whitelist: vec!["10.44.0.0/24".to_string(), "10.45.0.0/24".to_string()],
enable_manual_routes: Some(true),
routes: vec!["10.60.0.0/16".to_string(), "10.61.0.0/16".to_string()],
port_forwards: vec![PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port: 0,
dst_ip: "10.88.0.8".to_string(),
dst_port: 80,
proto: "tcp".to_string(),
}],
disable_udp_hole_punching: Some(true),
disable_tcp_hole_punching: Some(true),
disable_sym_hole_punching: Some(true),
disable_upnp: Some(true),
disable_relay_data: Some(true),
enable_magic_dns: Some(true),
enable_private_mode: Some(true),
mtu: Some(1360),
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
encryption_algorithm: Some("xor".to_string()),
instance_recv_bps_limit: Some(123456),
enable_udp_broadcast_relay: Some(true),
socket_mark: Some(0),
..Default::default()
})
.unwrap()
}
fn redelivered_managed_network_config(inst_id: uuid::Uuid) -> serde_json::Value {
serde_json::to_value(NetworkConfig {
instance_id: Some(inst_id.to_string()),
dhcp: Some(false),
virtual_ipv4: Some("10.88.0.7".to_string()),
network_length: Some(24),
hostname: Some("managed-redelivered-host".to_string()),
network_name: Some("managed-redelivered".to_string()),
network_secret: Some("secret-updated".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
peer_urls: vec!["tcp://127.0.0.1:11010".to_string()],
proxy_cidrs: vec![
"10.44.0.0/24".to_string(),
"10.45.0.0/24->10.46.0.0/24".to_string(),
],
no_tun: Some(true),
disable_ipv6: Some(true),
enable_kcp_proxy: Some(true),
disable_kcp_input: Some(true),
relay_all_peer_rpc: Some(true),
need_p2p: Some(true),
multi_thread: Some(false),
enable_private_mode: Some(true),
mtu: Some(1360),
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
encryption_algorithm: Some("xor".to_string()),
instance_recv_bps_limit: Some(654321),
..Default::default()
})
.unwrap()
}
use crate::{FeatureFlags, client_manager::ClientManager, db::Db};
#[tokio::test]
async fn test_client() {
@@ -813,7 +348,6 @@ mod tests {
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Duration::ZERO,
Arc::new(FeatureFlags::default()),
Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
@@ -873,223 +407,4 @@ mod tests {
println!("{:?}", req);
println!("{:?}", mgr);
}
#[tokio::test]
async fn managed_web_config_revision_updates_running_core_config() {
let (webhook_config, webhook_server, _) = test_webhook_config().await;
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Duration::ZERO,
Arc::new(FeatureFlags::default()),
webhook_config,
);
let config_server_addr = add_random_udp_listener(&mut mgr).await;
let machine_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4();
let core_manager = Arc::new(NetworkInstanceManager::new());
let client =
start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await;
let user_id = wait_for_validated_user(&mgr, machine_id).await;
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
initial_managed_network_config(instance_id),
)],
Some("rev-initial".to_string()),
None,
)
.await
.unwrap();
wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-initial")
})
.await;
// Online revision update: web-owned running config is fully overwritten
// when non-hot-patch flags such as enable_kcp_proxy change.
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
updated_managed_network_config(instance_id),
)],
Some("rev-updated".to_string()),
Some("rev-initial".to_string()),
)
.await
.unwrap();
let updated = wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-updated")
&& config.enable_kcp_proxy == Some(true)
&& config.port_forwards.len() == 1
})
.await;
assert_updated_runtime_config(&updated, instance_id);
assert_eq!(
core_manager.get_instance_network_config_source(&instance_id),
Some(easytier::common::config::ConfigSource::Web)
);
assert_eq!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.as_deref(),
Some("rev-updated")
);
// Web DB loss path: clear web-owned config and revision, then simulate
// the webhook re-posting the authoritative desired config. The already
// connected session should receive the distinguishable re-delivered
// revision without restarting.
clear_managed_config_db(&mgr, user_id, machine_id, instance_id).await;
assert!(
mgr.db()
.get_network_config((user_id, machine_id), &instance_id.to_string())
.await
.unwrap()
.is_none()
);
assert!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.is_none()
);
mgr.reconcile_managed_network_configs(
user_id,
machine_id,
vec![managed_config(
instance_id,
redelivered_managed_network_config(instance_id),
)],
Some("rev-webhook-redelivery".to_string()),
None,
)
.await
.unwrap();
let redelivered = wait_for_runtime_config(&core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-redelivered")
&& config.instance_recv_bps_limit == Some(654321)
})
.await;
assert_eq!(
redelivered.instance_id.as_deref(),
Some(instance_id.to_string().as_str())
);
assert_eq!(
redelivered.hostname.as_deref(),
Some("managed-redelivered-host")
);
assert_eq!(
redelivered.network_name.as_deref(),
Some("managed-redelivered")
);
assert_eq!(redelivered.enable_kcp_proxy, Some(true));
assert_eq!(redelivered.instance_recv_bps_limit, Some(654321));
assert_eq!(
core_manager.get_instance_network_config_source(&instance_id),
Some(easytier::common::config::ConfigSource::Web)
);
assert_eq!(
mgr.db()
.get_managed_config_revision((user_id, machine_id))
.await
.unwrap()
.as_deref(),
Some("rev-webhook-redelivery")
);
// Reconnect path: a fresh core manager has no local runtime state, so
// the new session must replay the managed config persisted in web DB.
drop(client);
let reconnected_core_manager = Arc::new(NetworkInstanceManager::new());
let _reconnected_client = start_web_client_for_test(
config_server_addr,
machine_id,
reconnected_core_manager.clone(),
)
.await;
wait_for_validated_user(&mgr, machine_id).await;
let replayed = wait_for_runtime_config(&reconnected_core_manager, instance_id, |config| {
config.network_name.as_deref() == Some("managed-redelivered")
&& config.instance_recv_bps_limit == Some(654321)
})
.await;
assert_eq!(
replayed.network_name.as_deref(),
Some("managed-redelivered")
);
assert_eq!(replayed.enable_kcp_proxy, Some(true));
assert_eq!(replayed.instance_recv_bps_limit, Some(654321));
webhook_server.abort();
}
#[tokio::test]
async fn webhook_reject_disconnects_and_revalidates_after_reconnect() {
let webhook_state = TestWebhookState::with_blocked_second_validate([false, true]);
let (webhook_config, webhook_server, webhook_state) =
test_webhook_config_with_state(webhook_state).await;
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Duration::ZERO,
Arc::new(FeatureFlags::default()),
webhook_config,
);
let config_server_addr = add_random_udp_listener(&mut mgr).await;
let machine_id = uuid::Uuid::new_v4();
let core_manager = Arc::new(NetworkInstanceManager::new());
let client =
start_web_client_for_test(config_server_addr, machine_id, core_manager.clone()).await;
let first_session_urls = wait_for_session_urls(&mgr).await;
wait_for_validate_count(&webhook_state, 1).await;
wait_for_validate_count(&webhook_state, 2).await;
assert!(
mgr.list_sessions().await.is_empty(),
"invalid validate-token response must not authorize the session"
);
webhook_state.allow_second_validate();
let user_id = wait_for_validated_user(&mgr, machine_id).await;
tokio::time::timeout(Duration::from_secs(12), async {
loop {
let reconnected = mgr
.client_sessions
.iter()
.any(|entry| !first_session_urls.iter().any(|url| url == entry.key()));
if reconnected {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap();
assert!(
client.is_connected(),
"web client should reconnect after invalid session heartbeat failure"
);
assert!(webhook_state.validate_count() >= 2);
assert!(
mgr.get_session_by_machine_id(user_id, &machine_id)
.is_some()
);
webhook_server.abort();
}
}
@@ -1,812 +0,0 @@
use anyhow::Context as _;
use easytier::{
common::config::{
ConfigLoader, EncryptionAlgorithm, PortForwardConfig as RuntimePortForwardConfig,
},
proto::{
acl::Acl,
api::{
config::{
AclPatch, ConfigPatchAction, InstanceConfigPatch, PatchConfigRequest,
PortForwardPatch, ProxyNetworkPatch,
},
instance::{InstanceIdentifier, instance_identifier},
manage::{
ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest, NetworkConfig,
RunNetworkInstanceRequest,
},
},
common::{CompressionAlgoPb, Ipv4Inet as RpcIpv4Inet},
rpc_types::controller::BaseController,
},
};
use super::session::{SessionConfigClient, SessionRpcClient};
pub(super) enum RuntimeReconcileAction {
None,
Run {
config: Box<NetworkConfig>,
overwrite: bool,
},
Patch(Box<InstanceConfigPatch>),
}
#[derive(Clone, PartialEq)]
struct RuntimeProxyNetwork {
cidr: String,
mapped_cidr: Option<String>,
}
fn instance_identifier(inst_id: &str) -> anyhow::Result<InstanceIdentifier> {
let inst_id = uuid::Uuid::parse_str(inst_id)
.with_context(|| format!("invalid runtime instance id: {inst_id}"))?;
Ok(InstanceIdentifier {
selector: Some(instance_identifier::Selector::Id(inst_id.into())),
})
}
fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result<NetworkConfig> {
let data_compress_algo = normalized_data_compress_algo(config.data_compress_algo);
let encryption_algorithm = normalized_encryption_algorithm(config.encryption_algorithm.clone());
let mut config = NetworkConfig::new_from_config(config.gen_config()?)?;
let is_credential_mode = config.network_secret.is_none()
&& config
.secure_mode
.as_ref()
.and_then(|mode| mode.local_private_key.as_deref())
.is_some_and(|key| !key.is_empty());
config.acl = None;
config.port_forwards.clear();
config.proxy_cidrs.clear();
config.disable_relay_data = None;
if config.dhcp.unwrap_or_default() {
config.virtual_ipv4 = None;
config.network_length = None;
}
if let Some(secure_mode) = config.secure_mode.as_mut() {
if !is_credential_mode {
secure_mode.local_private_key = None;
}
secure_mode.local_public_key = None;
}
config.data_compress_algo = data_compress_algo;
config.encryption_algorithm = encryption_algorithm;
Ok(config)
}
fn normalized_data_compress_algo(algo: Option<i32>) -> Option<i32> {
let default = CompressionAlgoPb::None as i32;
let effective = algo.map(|algo| if algo < default { default } else { algo });
effective.filter(|algo| *algo != default)
}
fn normalized_encryption_algorithm(algo: Option<String>) -> Option<String> {
let default = EncryptionAlgorithm::default().to_string();
algo.filter(|algo| algo != &default)
}
fn diff_port_forwards(
current: &[RuntimePortForwardConfig],
desired: &[RuntimePortForwardConfig],
) -> Vec<PortForwardPatch> {
let mut patches = Vec::new();
for cfg in unique_port_forwards(current, desired) {
let current_count = current.iter().filter(|item| *item == &cfg).count();
let desired_count = desired.iter().filter(|item| *item == &cfg).count();
if current_count == desired_count {
continue;
}
if current_count > 0 {
patches.push(PortForwardPatch {
action: ConfigPatchAction::Remove as i32,
cfg: Some(cfg.clone().into()),
});
}
patches.extend((0..desired_count).map(|_| PortForwardPatch {
action: ConfigPatchAction::Add as i32,
cfg: Some(cfg.clone().into()),
}));
}
patches
}
fn unique_port_forwards(
current: &[RuntimePortForwardConfig],
desired: &[RuntimePortForwardConfig],
) -> Vec<RuntimePortForwardConfig> {
let mut unique = Vec::new();
for cfg in current.iter().chain(desired.iter()) {
if !unique.contains(cfg) {
unique.push(cfg.clone());
}
}
unique
}
fn parse_rpc_ipv4_inet(value: &str) -> anyhow::Result<RpcIpv4Inet> {
value
.parse::<RpcIpv4Inet>()
.with_context(|| format!("failed to parse runtime ipv4 cidr: {value}"))
}
fn diff_proxy_networks(
current: &[RuntimeProxyNetwork],
desired: &[RuntimeProxyNetwork],
) -> anyhow::Result<Vec<ProxyNetworkPatch>> {
if current == desired {
return Ok(Vec::new());
}
let mut patches = vec![ProxyNetworkPatch {
action: ConfigPatchAction::Clear as i32,
cidr: Some(clear_proxy_network_cidr(current, desired)?),
..Default::default()
}];
for proxy_network in desired {
patches.push(ProxyNetworkPatch {
action: ConfigPatchAction::Add as i32,
cidr: Some(parse_rpc_ipv4_inet(&proxy_network.cidr)?),
mapped_cidr: proxy_network
.mapped_cidr
.as_deref()
.map(parse_rpc_ipv4_inet)
.transpose()?,
});
}
Ok(patches)
}
fn clear_proxy_network_cidr(
current: &[RuntimeProxyNetwork],
desired: &[RuntimeProxyNetwork],
) -> anyhow::Result<RpcIpv4Inet> {
let cidr = desired
.first()
.or_else(|| current.first())
.map(|proxy_network| proxy_network.cidr.as_str())
.unwrap_or("0.0.0.0/0");
parse_rpc_ipv4_inet(cidr)
}
fn normalized_acl(acl: &Option<Acl>) -> Option<Acl> {
let acl = acl.clone().unwrap_or_default();
(acl != Acl::default()).then_some(acl)
}
fn normalized_port_forwards(
config: &NetworkConfig,
) -> anyhow::Result<Vec<RuntimePortForwardConfig>> {
Ok(config
.gen_config()?
.get_port_forwards()
.into_iter()
.map(|cfg| {
RuntimePortForwardConfig::from(easytier::proto::common::PortForwardConfigPb::from(cfg))
})
.collect())
}
fn normalized_proxy_networks(config: &NetworkConfig) -> anyhow::Result<Vec<RuntimeProxyNetwork>> {
Ok(config
.gen_config()?
.get_proxy_cidrs()
.into_iter()
.map(|proxy_network| RuntimeProxyNetwork {
cidr: proxy_network.cidr.to_string(),
mapped_cidr: proxy_network.mapped_cidr.map(|cidr| cidr.to_string()),
})
.collect())
}
fn normalized_disable_relay_data(config: &NetworkConfig) -> anyhow::Result<bool> {
Ok(config.gen_config()?.get_flags().disable_relay_data)
}
fn web_source_runtime_patch(
current: &NetworkConfig,
desired: &NetworkConfig,
) -> anyhow::Result<Option<InstanceConfigPatch>> {
if let Some(desired_hostname) = desired
.hostname
.as_deref()
.filter(|hostname| !hostname.is_empty())
&& current.hostname.as_deref() != Some(desired_hostname)
{
return Ok(None);
}
let mut current_base = hot_patch_base(current)?;
let mut desired_base = hot_patch_base(desired)?;
current_base.hostname = None;
desired_base.hostname = None;
if current_base != desired_base {
return Ok(None);
}
let mut patch = InstanceConfigPatch::default();
let current_acl = normalized_acl(&current.acl);
let desired_acl = normalized_acl(&desired.acl);
if current_acl != desired_acl {
patch.acl = Some(AclPatch {
acl: Some(desired_acl.unwrap_or_default()),
..Default::default()
});
}
let current_port_forwards = normalized_port_forwards(current)?;
let desired_port_forwards = normalized_port_forwards(desired)?;
if current_port_forwards != desired_port_forwards {
patch.port_forwards = diff_port_forwards(&current_port_forwards, &desired_port_forwards);
}
let current_proxy_networks = normalized_proxy_networks(current)?;
let desired_proxy_networks = normalized_proxy_networks(desired)?;
if current_proxy_networks != desired_proxy_networks {
if current_proxy_networks.is_empty() {
return Ok(None);
}
patch.proxy_networks =
diff_proxy_networks(&current_proxy_networks, &desired_proxy_networks)?;
}
let current_disable_relay_data = normalized_disable_relay_data(current)?;
let desired_disable_relay_data = normalized_disable_relay_data(desired)?;
if current_disable_relay_data != desired_disable_relay_data {
patch.disable_relay_data = Some(desired_disable_relay_data);
}
Ok(Some(patch))
}
fn ensure_runtime_config_converged(
current: &NetworkConfig,
desired: &NetworkConfig,
) -> anyhow::Result<()> {
let patch = web_source_runtime_patch(current, desired)?;
match patch {
Some(patch) if patch == InstanceConfigPatch::default() => Ok(()),
Some(patch) => anyhow::bail!("runtime config still needs patch after reconcile: {patch:?}"),
None => anyhow::bail!("runtime config still needs full overwrite after reconcile"),
}
}
async fn run_web_source_instance(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
config: NetworkConfig,
overwrite: bool,
) -> anyhow::Result<()> {
rpc_client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(inst_id.to_string().into()),
config: Some(config),
overwrite,
source: RpcConfigSource::Web as i32,
},
)
.await?;
Ok(())
}
pub(super) async fn get_runtime_config(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
) -> anyhow::Result<NetworkConfig> {
rpc_client
.get_network_instance_config(
BaseController::default(),
GetNetworkInstanceConfigRequest {
inst_id: Some(inst_id.to_string().into()),
},
)
.await?
.config
.ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}"))
}
pub(super) async fn prepare_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
desired_config: NetworkConfig,
is_running: bool,
) -> anyhow::Result<RuntimeReconcileAction> {
if !is_running {
return Ok(RuntimeReconcileAction::Run {
config: Box::new(desired_config),
overwrite: false,
});
}
let current_config = get_runtime_config(rpc_client, inst_id).await?;
prepare_web_source_runtime_reconcile_from_current(&current_config, desired_config)
}
pub(super) fn prepare_web_source_runtime_reconcile_from_current(
current_config: &NetworkConfig,
desired_config: NetworkConfig,
) -> anyhow::Result<RuntimeReconcileAction> {
let Some(patch) = web_source_runtime_patch(current_config, &desired_config)? else {
return Ok(RuntimeReconcileAction::Run {
config: Box::new(desired_config),
overwrite: true,
});
};
if patch == InstanceConfigPatch::default() {
return Ok(RuntimeReconcileAction::None);
}
Ok(RuntimeReconcileAction::Patch(Box::new(patch)))
}
pub(super) async fn apply_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
inst_id: &str,
desired_config: NetworkConfig,
action: RuntimeReconcileAction,
) -> anyhow::Result<NetworkConfig> {
match action {
RuntimeReconcileAction::None => Ok(desired_config),
RuntimeReconcileAction::Run { config, overwrite } => {
run_web_source_instance(rpc_client, inst_id, *config, overwrite).await?;
Ok(desired_config)
}
RuntimeReconcileAction::Patch(patch) => {
config_client
.patch_config(
BaseController::default(),
PatchConfigRequest {
instance: Some(instance_identifier(inst_id)?),
patch: Some(*patch),
},
)
.await?;
let current_config = get_runtime_config(rpc_client, inst_id).await?;
ensure_runtime_config_converged(&current_config, &desired_config)?;
Ok(current_config)
}
}
}
#[cfg(test)]
mod tests {
use easytier::proto::{
api::{
config::ConfigPatchAction,
manage::{NetworkingMethod, PortForwardConfig},
},
common::{CompressionAlgoPb, SocketType},
};
use super::*;
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
NetworkConfig {
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
dhcp: Some(true),
network_name: Some("managed".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
port_forwards,
..Default::default()
}
}
fn port_forward(bind_port: u32, dst_port: u32) -> PortForwardConfig {
PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port,
dst_ip: "10.144.0.1".to_string(),
dst_port,
proto: "tcp".to_string(),
}
}
fn patch_port(patch: &PortForwardPatch) -> (i32, u32, u32, i32) {
let cfg = patch.cfg.as_ref().expect("port forward patch cfg");
(
patch.action,
cfg.bind_addr.as_ref().expect("bind addr").port,
cfg.dst_addr.as_ref().expect("dst addr").port,
cfg.socket_type,
)
}
fn patch_proxy_network(patch: &ProxyNetworkPatch) -> (i32, String, Option<String>) {
(
patch.action,
patch.cidr.map(|cidr| cidr.to_string()).unwrap_or_default(),
patch.mapped_cidr.map(|cidr| cidr.to_string()),
)
}
#[test]
fn runtime_patch_ignores_runtime_defaults_and_adds_port_forward() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.virtual_ipv4 = Some("10.144.0.2".to_string());
current.network_length = Some(16);
current.bind_device = Some(true);
current.dev_name = Some(String::new());
current.disable_ipv6 = Some(false);
current.mtu = Some(1380);
current.multi_thread = Some(true);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_removes_deleted_port_forward_without_clear() {
let current =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Remove as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_reconciles_duplicate_port_forward_count() {
let current =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23000, 5174)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 2);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Remove as i32,
23000,
5174,
SocketType::Tcp as i32
)
);
assert_eq!(
patch_port(&patch.port_forwards[1]),
(
ConfigPatchAction::Add as i32,
23000,
5174,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_convergence_rejects_stale_extra_port_forward() {
let current = config_with_port_forwards(vec![
port_forward(23000, 5174),
port_forward(23007, 3389),
port_forward(23100, 8080),
]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let err = ensure_runtime_config_converged(&current, &desired)
.expect_err("extra runtime port forward should not converge");
assert!(
err.to_string()
.contains("runtime config still needs patch after reconcile"),
"unexpected error: {err:?}"
);
}
#[test]
fn runtime_patch_canonicalizes_port_forward_protocol() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired_port_forward = port_forward(23000, 5174);
desired_port_forward.proto = "TCP".to_string();
let desired = config_with_port_forwards(vec![desired_port_forward]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch, InstanceConfigPatch::default());
ensure_runtime_config_converged(&current, &desired).expect("runtime converged");
}
#[test]
fn runtime_patch_rejects_non_hot_config_change() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.network_secret = Some("new-secret".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_routes_change() {
let mut current = config_with_port_forwards(Vec::new());
current.enable_manual_routes = Some(true);
current.routes = vec!["10.1.0.0/16".to_string(), "10.2.0.0/16".to_string()];
let mut desired = config_with_port_forwards(Vec::new());
desired.enable_manual_routes = Some(true);
desired.routes = vec!["10.2.0.0/16".to_string(), "10.3.0.0/16".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_replaces_proxy_networks() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec![
"10.1.0.0/16".to_string(),
"10.2.0.0/16->10.20.0.0/16".to_string(),
];
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec![
"10.2.0.0/16->10.21.0.0/16".to_string(),
"10.3.0.0/16".to_string(),
];
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 3);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.2.0.0/16".to_string(),
None
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[1]),
(
ConfigPatchAction::Add as i32,
"10.2.0.0/16".to_string(),
Some("10.21.0.0/16".to_string())
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[2]),
(
ConfigPatchAction::Add as i32,
"10.3.0.0/16".to_string(),
None
)
);
}
#[test]
fn runtime_patch_replaces_proxy_networks_with_same_source_cidr() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec![
"10.1.2.0/24".to_string(),
"10.1.2.0/24->10.1.3.0/24".to_string(),
];
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24->10.1.3.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 2);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.1.2.0/24".to_string(),
None
)
);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[1]),
(
ConfigPatchAction::Add as i32,
"10.1.2.0/24".to_string(),
Some("10.1.3.0/24".to_string())
)
);
}
#[test]
fn runtime_patch_rejects_proxy_network_empty_to_nonempty() {
let current = config_with_port_forwards(Vec::new());
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_clears_proxy_networks_with_legacy_compatible_cidr() {
let mut current = config_with_port_forwards(Vec::new());
current.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let desired = config_with_port_forwards(Vec::new());
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.proxy_networks.len(), 1);
assert_eq!(
patch_proxy_network(&patch.proxy_networks[0]),
(
ConfigPatchAction::Clear as i32,
"10.1.2.0/24".to_string(),
None
)
);
}
#[test]
fn runtime_patch_updates_disable_relay_data() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.disable_relay_data = Some(true);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.disable_relay_data, Some(true));
}
#[test]
fn runtime_patch_still_rejects_unsupported_flag_change() {
let current = config_with_port_forwards(Vec::new());
let mut desired = current.clone();
desired.no_tun = Some(true);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_encryption_algorithm_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired = current.clone();
desired.encryption_algorithm = Some("managed-test-algo".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_data_compress_algo_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired = current.clone();
desired.data_compress_algo = Some(CompressionAlgoPb::Zstd as i32);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_rejects_credential_private_key_change() {
let mut current = config_with_port_forwards(Vec::new());
current.network_secret = None;
current.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("mUuD5fsIm/ftvgS4WBAYFMNLqWX3qT9rnm4PrnOqb9s=".to_string()),
local_public_key: None,
});
let mut desired = current.clone();
desired.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("aEpz80FuYbaY4QLJizAIuIcK4TYsoSA9jHHCXCOQJoc=".to_string()),
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
#[test]
fn runtime_patch_ignores_generated_secure_key_when_network_secret_exists() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some("mUuD5fsIm/ftvgS4WBAYFMNLqWX3qT9rnm4PrnOqb9s=".to_string()),
local_public_key: Some("4x6L5dZjB8hsPO4f96Hyhi4xFealBu6i3BxRVBYR1Fc=".to_string()),
});
let mut desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
desired.secure_mode = Some(easytier::proto::common::SecureModeConfig {
enabled: true,
local_private_key: None,
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_ignores_runtime_hostname_when_desired_omits_hostname() {
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
current.hostname = Some("runtime-host".to_string());
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
.expect("build patch")
.expect("hot patch");
assert_eq!(patch.port_forwards.len(), 1);
assert_eq!(
patch_port(&patch.port_forwards[0]),
(
ConfigPatchAction::Add as i32,
23007,
3389,
SocketType::Tcp as i32
)
);
}
#[test]
fn runtime_patch_rejects_explicit_desired_hostname_change() {
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let mut desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
desired.hostname =
Some(easytier::common::config::TomlConfigLoader::default().get_hostname());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
assert!(patch.is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -1,913 +0,0 @@
use std::collections::{HashMap, HashSet};
use easytier::{
proto::{
api::manage::{
DeleteNetworkInstanceRequest, ListNetworkInstanceMetaRequest,
ListNetworkInstanceRequest, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest,
},
rpc_types::controller::BaseController,
web::HeartbeatRequest,
},
rpc_service::remote_client::{ListNetworkProps, Storage as _},
};
use tokio::sync::{RwLock, broadcast};
use super::{SessionConfigClient, SessionData, SessionRpcClient, SessionRpcService};
use crate::client_manager::{
managed_config::{self, PersistedConfigSource},
runtime_reconcile,
storage::{StorageInner, WeakRefStorage},
};
async fn recv_latest_heartbeat(
heartbeat_waiter: &mut broadcast::Receiver<HeartbeatRequest>,
) -> Option<HeartbeatRequest> {
let mut req = loop {
match heartbeat_waiter.recv().await {
Ok(req) => break req,
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"heartbeat reconcile worker lagged, waiting for latest request"
);
}
Err(broadcast::error::RecvError::Closed) => {
tracing::error!("Failed to receive heartbeat request: channel closed");
return None;
}
}
};
// Drop any heartbeat backlog accumulated while the previous reconcile
// round was doing DB/RPC IO. The newest heartbeat has the freshest
// runtime instance list, which is all this task needs.
loop {
match heartbeat_waiter.try_recv() {
Ok(next_req) => req = next_req,
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => return None,
}
}
Some(req)
}
pub(super) async fn reconcile_network_configs_on_heartbeat(
session_data: std::sync::Weak<RwLock<SessionData>>,
mut heartbeat_waiter: broadcast::Receiver<HeartbeatRequest>,
storage: WeakRefStorage,
mut rpc_client: SessionRpcClient,
mut config_client: SessionConfigClient,
) {
let mut cache = ReconcileCache::default();
loop {
let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else {
return;
};
let Some(storage) = storage.upgrade() else {
tracing::error!("Failed to get storage");
return;
};
let mut round =
match prepare_reconcile_round(&session_data, &storage, &mut rpc_client, req).await {
RoundStatus::Ready(round) => round,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
let running_metas =
match sync_running_sources_for_round(&mut rpc_client, &storage, &mut round).await {
RoundStatus::Ready(running_metas) => running_metas,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
let desired_web_inst_ids =
managed_config::desired_web_source_instance_ids(&round.local_configs);
cache.runtime_configs.retain_desired(&desired_web_inst_ids);
let mut outcome = match cleanup_stale_web_source_instances(
&session_data,
&storage,
&mut rpc_client,
&round,
running_metas.as_deref(),
&desired_web_inst_ids,
&mut cache,
)
.await
{
RoundStatus::Ready(outcome) => outcome,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
outcome.merge(
reconcile_desired_runtime_configs(
&session_data,
&mut rpc_client,
&mut config_client,
&round,
&mut cache,
)
.await,
);
if !outcome.has_failed {
cache.last_desired_web_inst_ids = Some(desired_web_inst_ids);
}
match mark_config_revision_applied_if_current(&session_data, &storage, &round, &outcome)
.await
{
RoundStatus::Ready(()) | RoundStatus::Skip => {}
RoundStatus::Stop => return,
}
}
}
enum RoundStatus<T> {
Ready(T),
Skip,
Stop,
}
enum ConfigActionResult {
Success,
Failed,
StopRound,
}
#[derive(Default)]
struct ReconcileCache {
cleaned_web_source_instances: bool,
last_desired_web_inst_ids: Option<HashSet<String>>,
runtime_configs: SessionRuntimeConfigCache,
}
#[derive(Default)]
struct SessionRuntimeConfigCache {
entries: HashMap<String, NetworkConfig>,
}
impl SessionRuntimeConfigCache {
fn plan(
&self,
inst_id: &str,
desired_config: NetworkConfig,
) -> anyhow::Result<Option<runtime_reconcile::RuntimeReconcileAction>> {
let Some(observed_config) = self.entries.get(inst_id) else {
return Ok(None);
};
runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
observed_config,
desired_config,
)
.map(Some)
}
fn remember(&mut self, inst_id: &str, observed_config: NetworkConfig) {
self.entries.insert(inst_id.to_string(), observed_config);
}
fn forget(&mut self, inst_id: &str) {
self.entries.remove(inst_id);
}
fn forget_many<'a>(&mut self, inst_ids: impl IntoIterator<Item = &'a String>) {
for inst_id in inst_ids {
self.entries.remove(inst_id);
}
}
fn retain_desired(&mut self, desired_web_inst_ids: &HashSet<String>) {
self.entries
.retain(|inst_id, _| desired_web_inst_ids.contains(inst_id));
}
}
#[derive(Default)]
struct ReconcileOutcome {
has_failed: bool,
managed_revision_failed: bool,
}
impl ReconcileOutcome {
fn record_failure(&mut self, managed_revision_failed: bool) {
self.has_failed = true;
self.managed_revision_failed |= managed_revision_failed;
}
fn merge(&mut self, other: Self) {
self.has_failed |= other.has_failed;
self.managed_revision_failed |= other.managed_revision_failed;
}
}
struct ReconcileRound {
req: HeartbeatRequest,
machine_id: uuid::Uuid,
user_id: i32,
running_inst_ids: HashSet<String>,
local_configs: Vec<crate::db::entity::user_running_network_configs::Model>,
target_config_revision: Option<String>,
should_apply_runtime_revision: bool,
}
async fn prepare_reconcile_round(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
req: HeartbeatRequest,
) -> RoundStatus<ReconcileRound> {
let Some(machine_id) = req.machine_id.map(uuid::Uuid::from) else {
tracing::warn!(?req, "Machine id is not set, ignore");
return RoundStatus::Skip;
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &req).await {
tracing::debug!(?machine_id, "skip stale heartbeat reconcile request");
return RoundStatus::Skip;
}
let user_id = match storage
.db
.get_user_id_by_token(req.user_token.clone())
.await
{
Ok(Some(user_id)) => user_id,
Ok(None) => {
tracing::info!("User not found by token: {:?}", req.user_token);
return RoundStatus::Stop;
}
Err(e) => {
tracing::error!("Failed to get user id by token, error: {:?}", e);
return RoundStatus::Stop;
}
};
let applied_config_revision = {
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
data.read().await.applied_config_revision.clone()
};
let target_config_revision = match storage
.db
.get_managed_config_revision((user_id, machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to read managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
let should_apply_runtime_revision =
target_config_revision.is_some() && target_config_revision != applied_config_revision;
let running_inst_ids = match running_instance_ids_for_round(
rpc_client,
&req,
user_id,
machine_id,
should_apply_runtime_revision,
)
.await
{
RoundStatus::Ready(ids) => ids,
RoundStatus::Skip => return RoundStatus::Skip,
RoundStatus::Stop => return RoundStatus::Stop,
};
let local_configs = match storage
.db
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!("Failed to list network configs, error: {:?}", e);
return RoundStatus::Stop;
}
};
RoundStatus::Ready(ReconcileRound {
req,
machine_id,
user_id,
running_inst_ids,
local_configs,
target_config_revision,
should_apply_runtime_revision,
})
}
async fn running_instance_ids_for_round(
rpc_client: &mut SessionRpcClient,
req: &HeartbeatRequest,
user_id: i32,
machine_id: uuid::Uuid,
should_apply_runtime_revision: bool,
) -> RoundStatus<HashSet<String>> {
if !should_apply_runtime_revision {
return RoundStatus::Ready(
req.running_network_instances
.iter()
.map(|x| x.to_string())
.collect(),
);
}
match rpc_client
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
.await
{
Ok(resp) => RoundStatus::Ready(resp.inst_ids.iter().map(|x| x.to_string()).collect()),
Err(error) => {
tracing::warn!(
?user_id,
?machine_id,
?error,
"Failed to refresh running instances for managed config revision"
);
RoundStatus::Skip
}
}
}
async fn sync_running_sources_for_round(
rpc_client: &mut SessionRpcClient,
storage: &StorageInner,
round: &mut ReconcileRound,
) -> RoundStatus<Option<Vec<NetworkMeta>>> {
if !round.req.support_config_source {
return RoundStatus::Ready(None);
}
let ret = if round.running_inst_ids.is_empty() {
Ok(Vec::new())
} else {
rpc_client
.list_network_instance_meta(
BaseController::default(),
ListNetworkInstanceMetaRequest {
inst_ids: managed_config::parse_instance_ids(
round.running_inst_ids.iter().cloned(),
),
},
)
.await
.map(|resp| resp.metas)
};
match ret {
Ok(metas) => {
if let Err(e) = managed_config::sync_running_config_sources(
&storage.db,
round.user_id,
round.machine_id,
&round.local_configs,
&metas,
)
.await
{
tracing::warn!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
%e,
"Failed to sync running network config sources"
);
} else if !metas.is_empty() {
round.local_configs = match storage
.db
.list_network_configs(
(round.user_id, round.machine_id),
ListNetworkProps::EnabledOnly,
)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!(
"Failed to reload network configs after source sync, error: {:?}",
e
);
return RoundStatus::Stop;
}
};
}
RoundStatus::Ready(Some(metas))
}
Err(e) => {
tracing::warn!(
user_id = ?round.user_id,
%e,
"Failed to list running network instance metadata"
);
RoundStatus::Ready(None)
}
}
}
async fn cleanup_stale_web_source_instances(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
running_metas: Option<&[NetworkMeta]>,
desired_web_inst_ids: &HashSet<String>,
cache: &mut ReconcileCache,
) -> RoundStatus<ReconcileOutcome> {
let desired_changed = cache
.last_desired_web_inst_ids
.as_ref()
.is_none_or(|last| last != desired_web_inst_ids);
if cache.cleaned_web_source_instances && !desired_changed {
return RoundStatus::Ready(ReconcileOutcome::default());
}
let db_web_inst_ids = match storage
.db
.list_network_configs((round.user_id, round.machine_id), ListNetworkProps::All)
.await
{
Ok(configs) => managed_config::desired_web_source_instance_ids(&configs),
Err(e) => {
tracing::error!("Failed to list all network configs, error: {:?}", e);
return RoundStatus::Stop;
}
};
let running_web_inst_ids = managed_config::running_web_source_instance_ids(
&round.running_inst_ids,
&db_web_inst_ids,
running_metas,
);
let should_delete_inst_ids = running_web_inst_ids
.difference(desired_web_inst_ids)
.cloned()
.collect::<HashSet<_>>();
let should_delete_ids =
managed_config::parse_instance_ids(should_delete_inst_ids.iter().cloned());
let mut outcome = ReconcileOutcome::default();
if !should_delete_ids.is_empty() {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
"skip stale cleanup because webhook session is no longer current"
);
return RoundStatus::Skip;
}
let ret = rpc_client
.delete_network_instance(
BaseController::default(),
DeleteNetworkInstanceRequest {
inst_ids: should_delete_ids,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Clean stale web-source network instances on heartbeat: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_err() {
outcome.record_failure(true);
} else {
cache.runtime_configs.forget_many(&should_delete_inst_ids);
}
}
if !outcome.has_failed {
cache.cleaned_web_source_instances = true;
cache.last_desired_web_inst_ids = Some(desired_web_inst_ids.clone());
}
RoundStatus::Ready(outcome)
}
async fn reconcile_desired_runtime_configs(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
round: &ReconcileRound,
cache: &mut ReconcileCache,
) -> ReconcileOutcome {
let mut outcome = ReconcileOutcome::default();
// After stale web-owned instances are removed, start every enabled
// config that the latest heartbeat did not report as running. When
// a managed config revision is pending, also reconcile running
// web-owned configs before reporting that revision as applied.
for config in &round.local_configs {
let source = PersistedConfigSource::from_db(&config.source);
let is_running = round.running_inst_ids.contains(&config.network_instance_id);
let should_reconcile_running_web_config = is_running
&& round.should_apply_runtime_revision
&& source == PersistedConfigSource::Web;
if is_running && !should_reconcile_running_web_config {
continue;
}
let desired_config = match serde_json::from_str::<NetworkConfig>(&config.network_config) {
Ok(cfg) => cfg,
Err(e) => {
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to deserialize network config, skipping: {:?}",
e
);
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
continue;
}
};
let action_result = if should_reconcile_running_web_config {
reconcile_running_web_config(
session_data,
rpc_client,
config_client,
round,
config,
desired_config,
&mut cache.runtime_configs,
)
.await
} else {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
let action_result = run_missing_network_config(
session_data,
rpc_client,
round,
config,
desired_config.clone(),
)
.await;
if matches!(action_result, ConfigActionResult::Success)
&& source == PersistedConfigSource::Web
{
if let Err(e) = remember_web_runtime_config_after_run(
rpc_client,
&config.network_instance_id,
&desired_config,
&mut cache.runtime_configs,
)
.await
{
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to cache runtime config after run: {:?}",
e
);
ConfigActionResult::Failed
} else {
action_result
}
} else {
action_result
}
};
match action_result {
ConfigActionResult::Success => {}
ConfigActionResult::Failed => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web)
}
ConfigActionResult::StopRound => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
break;
}
}
}
outcome
}
async fn reconcile_running_web_config(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
round: &ReconcileRound,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> ConfigActionResult {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip runtime reconcile because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = async {
let action =
match runtime_config_cache.plan(&config.network_instance_id, desired_config.clone())? {
Some(action) => action,
None => {
runtime_reconcile::prepare_web_source_runtime_reconcile(
&mut *rpc_client,
&config.network_instance_id,
desired_config.clone(),
true,
)
.await?
}
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
anyhow::bail!("webhook session is no longer current before runtime reconcile apply");
}
let observed_config = runtime_reconcile::apply_web_source_runtime_reconcile(
&mut *rpc_client,
&mut *config_client,
&config.network_instance_id,
desired_config.clone(),
action,
)
.await?;
runtime_config_cache.remember(&config.network_instance_id, observed_config);
Ok::<(), anyhow::Error>(())
}
.await;
tracing::info!(
user_id = ?round.user_id,
instance_id = %config.network_instance_id,
"Reconcile running web-source network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
runtime_config_cache.forget(&config.network_instance_id);
ConfigActionResult::Failed
}
}
async fn run_missing_network_config(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
) -> ConfigActionResult {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip run network instance because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = rpc_client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(config.network_instance_id.clone().into()),
config: Some(desired_config),
overwrite: false,
source: PersistedConfigSource::from_db(&config.source).auto_run_rpc_source() as i32,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Run network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
ConfigActionResult::Failed
}
}
async fn remember_web_runtime_config_after_run(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
desired_config: &NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let observed_config = runtime_reconcile::get_runtime_config(rpc_client, inst_id).await?;
remember_if_runtime_matches_desired(
inst_id,
desired_config,
observed_config,
runtime_config_cache,
)
}
fn remember_if_runtime_matches_desired(
inst_id: &str,
desired_config: &NetworkConfig,
observed_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let action = runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
&observed_config,
desired_config.clone(),
)?;
if !matches!(action, runtime_reconcile::RuntimeReconcileAction::None) {
anyhow::bail!("runtime config still differs after managed run");
}
runtime_config_cache.remember(inst_id, observed_config);
Ok(())
}
async fn mark_config_revision_applied_if_current(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
round: &ReconcileRound,
outcome: &ReconcileOutcome,
) -> RoundStatus<()> {
if outcome.managed_revision_failed || !round.should_apply_runtime_revision {
return RoundStatus::Ready(());
}
let current_target_config_revision = match storage
.db
.get_managed_config_revision((round.user_id, round.machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to verify managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
if current_target_config_revision != round.target_config_revision {
return RoundStatus::Ready(());
}
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
return RoundStatus::Ready(());
}
data.applied_config_revision = round.target_config_revision.clone();
RoundStatus::Ready(())
}
#[cfg(test)]
mod tests {
use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig};
use super::*;
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
NetworkConfig {
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
dhcp: Some(true),
network_name: Some("managed".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
port_forwards,
..Default::default()
}
}
fn port_forward(bind_port: u32, dst_port: u32) -> PortForwardConfig {
PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port,
dst_ip: "10.144.0.1".to_string(),
dst_port,
proto: "tcp".to_string(),
}
}
#[test]
fn session_runtime_config_cache_misses_unknown_instance() {
let cache = SessionRuntimeConfigCache::default();
let action = cache
.plan("missing", config_with_port_forwards(Vec::new()))
.expect("prepare action");
assert!(action.is_none());
}
#[test]
fn session_runtime_config_cache_skips_matching_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
cache.remember("managed", config.clone());
let action = cache
.plan("managed", config)
.expect("prepare action")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn session_runtime_config_cache_plans_patch_from_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
cache.remember("managed", current);
let action = cache
.plan("managed", desired)
.expect("prepare action")
.expect("cached action");
let runtime_reconcile::RuntimeReconcileAction::Patch(patch) = action else {
panic!("expected cached runtime config to produce hot patch");
};
assert_eq!(patch.port_forwards.len(), 1);
}
#[test]
fn session_runtime_config_cache_retain_desired_removes_stale_entries() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("keep", config.clone());
cache.remember("drop", config);
cache.retain_desired(&HashSet::from(["keep".to_string()]));
assert!(cache.entries.contains_key("keep"));
assert!(!cache.entries.contains_key("drop"));
}
#[test]
fn session_runtime_config_cache_forget_removes_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("managed", config.clone());
cache.forget("managed");
let action = cache
.plan("managed", config)
.expect("prepare action after remove");
assert!(action.is_none());
}
#[test]
fn missing_run_remembers_observed_config_when_it_matches_desired() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
remember_if_runtime_matches_desired("managed", &config, config.clone(), &mut cache)
.expect("remember observed config after run");
let action = cache
.plan("managed", config)
.expect("prepare action after run")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn missing_run_does_not_remember_observed_config_that_still_differs() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let err = remember_if_runtime_matches_desired("managed", &desired, current, &mut cache)
.expect_err("expected stale run result not to be cached");
assert!(
err.to_string()
.contains("runtime config still differs after managed run")
);
let action = cache
.plan("managed", desired)
.expect("prepare action after stale run result");
assert!(action.is_none());
}
}
@@ -1,402 +0,0 @@
use std::{sync::Arc, time::Duration};
use anyhow::Context as _;
use easytier::proto::web::HeartbeatRequest;
use tokio::sync::RwLock;
use super::{
SessionAuthState, SessionData, SessionRpcService, WebhookConnectNotification,
WebhookDisconnectNotification, send_webhook_connection_transition,
};
use crate::{
client_manager::storage::{Storage, StorageToken},
webhook::SharedWebhookConfig,
};
pub(super) const VALIDATION_RETRY_MS: u64 = 60_000;
pub(super) struct WebhookHeartbeatValidation {
pub(super) config_revision: String,
pub(super) binding_version: u64,
}
pub(super) struct WebhookValidationInput {
pub(super) storage: Storage,
pub(super) webhook_config: SharedWebhookConfig,
pub(super) client_url: url::Url,
pub(super) applied_config_revision: Option<String>,
pub(super) req: HeartbeatRequest,
pub(super) machine_id: uuid::Uuid,
}
fn deterministic_machine_delay(machine_id: uuid::Uuid, max_delay_ms: u64) -> Duration {
let delay_ms = (machine_id.as_u128() % u128::from(max_delay_ms + 1)) as u64;
Duration::from_millis(delay_ms)
}
pub(super) fn retry_delay(machine_id: uuid::Uuid) -> Duration {
Duration::from_millis(VALIDATION_RETRY_MS)
+ deterministic_machine_delay(machine_id, VALIDATION_RETRY_MS)
}
async fn request_heartbeat_validation(
webhook_config: &crate::webhook::WebhookConfig,
client_url: &url::Url,
persisted_config_revision: Option<&str>,
applied_config_revision: Option<&str>,
req: &HeartbeatRequest,
machine_id: uuid::Uuid,
) -> anyhow::Result<Option<WebhookHeartbeatValidation>> {
let webhook_req = crate::webhook::ValidateTokenRequest {
token: req.user_token.clone(),
machine_id: machine_id.to_string(),
public_ip: client_url.host_str().map(str::to_string),
hostname: req.hostname.clone(),
version: req.easytier_version.clone(),
os_type: req.device_os.as_ref().map(|info| info.os_type.clone()),
os_version: req.device_os.as_ref().map(|info| info.version.clone()),
os_distribution: req.device_os.as_ref().map(|info| info.distribution.clone()),
web_instance_id: webhook_config.web_instance_id.clone(),
web_instance_api_base_url: webhook_config.web_instance_api_base_url.clone(),
persisted_config_revision: persisted_config_revision.map(str::to_string),
applied_config_revision: applied_config_revision.map(str::to_string),
};
let resp = webhook_config
.validate_token(&webhook_req)
.await
.map_err(|e| anyhow::anyhow!("Webhook token validation failed: {:?}", e))?;
if !resp.valid {
return Ok(None);
}
Ok(Some(WebhookHeartbeatValidation {
config_revision: resp.config_revision,
binding_version: resp.binding_version,
}))
}
async fn resolve_user_id(storage: &Storage, token: &str) -> anyhow::Result<i32> {
let user_id = match storage
.db()
.get_user_id_by_token(token)
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
{
Some(id) => id,
None => storage
.auto_create_user(token)
.await
.with_context(|| format!("Failed to auto-create webhook user: {:?}", token))?,
};
Ok(user_id)
}
async fn persisted_config_revision_for_token(
storage: &Storage,
token: &str,
machine_id: uuid::Uuid,
) -> anyhow::Result<Option<String>> {
let Some(user_id) = storage
.db()
.get_user_id_by_token(token)
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
else {
return Ok(None);
};
storage
.db()
.get_managed_config_revision((user_id, machine_id))
.await
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))
}
async fn wait_for_input(
session_data: std::sync::Weak<RwLock<SessionData>>,
) -> Option<WebhookValidationInput> {
loop {
let notify = {
let session_data = session_data.upgrade()?;
let mut data = session_data.write().await;
if matches!(data.auth_state, SessionAuthState::Invalid) {
data.webhook_validation_dirty = false;
tracing::info!(
client_url = %data.client_url,
"webhook validation stopped for invalid session; reconnect is required before revalidation"
);
return None;
}
if data.webhook_validation_dirty {
data.webhook_validation_dirty = false;
let req = data.req.clone()?;
let machine_id = req.machine_id.map(Into::into)?;
let storage = Storage::try_from(data.storage.clone()).ok()?;
return Some(WebhookValidationInput {
storage,
webhook_config: data.webhook_config.clone(),
client_url: data.client_url.clone(),
applied_config_revision: data.applied_config_revision.clone(),
req,
machine_id,
});
}
data.webhook_validation_notify.clone()
};
notify.notified().await;
}
}
pub(super) async fn run_worker(session_data: std::sync::Weak<RwLock<SessionData>>) {
while let Some(input) = wait_for_input(session_data.clone()).await {
let machine_id = input.machine_id;
if let Err(error) = run_round(session_data.clone(), input).await {
tracing::warn!(
?machine_id,
%error,
"webhook validation failed, will retry later"
);
tokio::time::sleep(retry_delay(machine_id)).await;
mark_dirty_if_current(&session_data, machine_id).await;
}
}
}
pub(super) async fn run_round(
session_data: std::sync::Weak<RwLock<SessionData>>,
input: WebhookValidationInput,
) -> anyhow::Result<()> {
let persisted_config_revision = persisted_config_revision_for_token(
&input.storage,
&input.req.user_token,
input.machine_id,
)
.await?;
let validation = request_heartbeat_validation(
&input.webhook_config,
&input.client_url,
persisted_config_revision.as_deref(),
input.applied_config_revision.as_deref(),
&input.req,
input.machine_id,
)
.await?;
let Some(validation) = validation else {
apply_rejected(&session_data, &input).await;
return Ok(());
};
let user_id = resolve_user_id(&input.storage, &input.req.user_token).await?;
apply_success(&session_data, input, validation, user_id).await;
Ok(())
}
async fn mark_dirty_if_current(
session_data: &std::sync::Weak<RwLock<SessionData>>,
machine_id: uuid::Uuid,
) {
let Some(session_data) = session_data.upgrade() else {
return;
};
let notify = {
let mut data = session_data.write().await;
let Some(req) = data.req.as_ref() else {
return;
};
if req.machine_id.map(uuid::Uuid::from) != Some(machine_id) {
return;
}
if matches!(data.auth_state, SessionAuthState::Invalid) {
data.webhook_validation_dirty = false;
tracing::debug!(
%machine_id,
"skip webhook validation retry for invalid session"
);
return;
}
SessionRpcService::mark_webhook_validation_dirty_locked(&mut data)
};
notify.notify_one();
}
pub(super) async fn apply_rejected(
session_data: &std::sync::Weak<RwLock<SessionData>>,
input: &WebhookValidationInput,
) {
let Some(session_data) = session_data.upgrade() else {
return;
};
let (storage_token, disconnect_notification) = {
let mut data = session_data.write().await;
if !data.req.as_ref().is_some_and(|req| {
SessionRpcService::heartbeat_matches_identity(
req,
&input.req.user_token,
input.machine_id,
)
}) {
return;
}
tracing::info!(
machine_id = %input.machine_id,
client_url = %data.client_url,
"webhook token rejected; marking session invalid and requiring client reconnect"
);
data.auth_state = SessionAuthState::Invalid;
data.webhook_validation_dirty = false;
data.binding_version = None;
data.applied_config_revision = None;
let storage_token = data.storage_token.clone();
let disconnect_notification = storage_token.as_ref().and_then(|storage_token| {
data.webhook_connected_binding_version
.take()
.map(|binding_version| WebhookDisconnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version,
})
});
(storage_token, disconnect_notification)
};
if let Some(storage_token) = storage_token {
let report_time = SessionRpcService::heartbeat_report_timestamp(&input.req);
input
.storage
.update_client(storage_token, report_time, false);
}
if disconnect_notification.is_some() {
wait_webhook_connection_transition(
Arc::downgrade(&session_data),
disconnect_notification,
None,
)
.await;
}
}
pub(super) async fn apply_success(
session_data: &std::sync::Weak<RwLock<SessionData>>,
input: WebhookValidationInput,
validation: WebhookHeartbeatValidation,
user_id: i32,
) {
let WebhookHeartbeatValidation {
config_revision: _,
binding_version,
} = validation;
let Some(session_data) = session_data.upgrade() else {
return;
};
let (storage_token, notifier, disconnect_notification, connect_notification, runtime_req) = {
let mut data = session_data.write().await;
let Some(runtime_req) = data.req.clone() else {
return;
};
if !SessionRpcService::heartbeat_matches_identity(
&runtime_req,
&input.req.user_token,
input.machine_id,
) {
return;
}
if matches!(data.auth_state, SessionAuthState::Invalid) {
tracing::info!(
machine_id = %input.machine_id,
client_url = %data.client_url,
"ignore webhook validation success for invalid session; reconnect is required before revalidation"
);
return;
}
let previous_connected_binding_version = data.webhook_connected_binding_version;
let client_url = data.client_url.clone();
let storage_token = data.storage_token.get_or_insert_with(|| StorageToken {
token: runtime_req.user_token.clone(),
client_url,
machine_id: input.machine_id,
user_id,
});
let storage_token = storage_token.clone();
data.auth_state = SessionAuthState::Authorized;
data.binding_version = Some(binding_version);
let should_notify_connected = previous_connected_binding_version != Some(binding_version);
let disconnect_notification = previous_connected_binding_version
.filter(|previous_binding_version| *previous_binding_version != binding_version)
.map(|previous_binding_version| {
data.webhook_connected_binding_version = None;
WebhookDisconnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version: previous_binding_version,
}
});
let connect_notification = should_notify_connected.then(|| WebhookConnectNotification {
webhook: data.webhook_config.clone(),
storage_token: storage_token.clone(),
binding_version,
req: crate::webhook::NodeConnectedRequest {
machine_id: input.machine_id.to_string(),
token: runtime_req.user_token.clone(),
user_id: Some(user_id),
hostname: runtime_req.hostname.clone(),
version: runtime_req.easytier_version.clone(),
os_type: runtime_req
.device_os
.as_ref()
.map(|info| info.os_type.clone()),
os_version: runtime_req
.device_os
.as_ref()
.map(|info| info.version.clone()),
os_distribution: runtime_req
.device_os
.as_ref()
.map(|info| info.distribution.clone()),
web_instance_id: data.webhook_config.web_instance_id.clone(),
binding_version: Some(binding_version),
},
});
(
storage_token,
data.notifier.clone(),
disconnect_notification,
connect_notification,
runtime_req,
)
};
if disconnect_notification.is_some() || connect_notification.is_some() {
wait_webhook_connection_transition(
Arc::downgrade(&session_data),
disconnect_notification,
connect_notification,
)
.await;
}
let report_time = SessionRpcService::heartbeat_report_timestamp(&runtime_req);
input
.storage
.update_client(storage_token, report_time, true);
let _ = notifier.send(runtime_req);
}
async fn wait_webhook_connection_transition(
session_data: std::sync::Weak<RwLock<SessionData>>,
disconnect: Option<WebhookDisconnectNotification>,
connect: Option<WebhookConnectNotification>,
) {
let transition = tokio::spawn(send_webhook_connection_transition(
session_data,
disconnect,
connect,
));
if let Err(error) = transition.await {
tracing::warn!(%error, "webhook connection transition task failed");
}
}
+7 -156
View File
@@ -17,7 +17,6 @@ pub struct StorageToken {
struct ClientInfo {
storage_token: StorageToken,
report_time: i64,
authorized: bool,
}
#[derive(Debug)]
@@ -56,19 +55,7 @@ impl Storage {
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
map.entry(client_info.storage_token.machine_id)
.and_modify(|e| {
let same_client = e.storage_token.client_url
== client_info.storage_token.client_url
&& e.storage_token.user_id == client_info.storage_token.user_id;
let should_replace = if (same_client && e.authorized != client_info.authorized)
|| (!e.authorized && client_info.authorized)
{
true
} else if e.authorized && !client_info.authorized && !same_client {
false
} else {
e.report_time < client_info.report_time
};
if should_replace {
if e.report_time < client_info.report_time {
assert_eq!(
e.storage_token.machine_id,
client_info.storage_token.machine_id
@@ -79,13 +66,12 @@ impl Storage {
.or_insert(client_info.clone());
}
pub fn update_client(&self, stoken: StorageToken, report_time: i64, authorized: bool) {
pub fn update_client(&self, stoken: StorageToken, report_time: i64) {
let inner = self.0.user_clients_map.entry(stoken.user_id).or_default();
let client_info = ClientInfo {
storage_token: stoken.clone(),
report_time,
authorized,
};
Self::update_client_info_map(&inner, &client_info);
}
@@ -107,21 +93,11 @@ impl Storage {
&self,
user_id: UserIdInDb,
machine_id: &uuid::Uuid,
) -> Option<url::Url> {
self.get_client_url_by_machine_id_with_auth(user_id, machine_id, true)
}
pub fn get_client_url_by_machine_id_with_auth(
&self,
user_id: UserIdInDb,
machine_id: &uuid::Uuid,
require_authorized: bool,
) -> Option<url::Url> {
self.0.user_clients_map.get(&user_id).and_then(|info_map| {
info_map.get(machine_id).and_then(|info| {
(!require_authorized || info.authorized)
.then(|| info.storage_token.client_url.clone())
})
info_map
.get(machine_id)
.map(|info| info.storage_token.client_url.clone())
})
}
@@ -132,36 +108,12 @@ impl Storage {
.map(|info_map| {
info_map
.iter()
.filter(|info| info.value().authorized)
.map(|info| info.value().storage_token.client_url.clone())
.collect()
})
.unwrap_or_default()
}
pub fn list_clients(&self) -> Vec<StorageToken> {
self.list_clients_with_auth(true)
}
pub fn list_all_clients(&self) -> Vec<StorageToken> {
self.list_clients_with_auth(false)
}
fn list_clients_with_auth(&self, require_authorized: bool) -> Vec<StorageToken> {
self.0
.user_clients_map
.iter()
.flat_map(|user_clients| {
user_clients
.value()
.iter()
.filter(|info| !require_authorized || info.value().authorized)
.map(|info| info.value().storage_token.clone())
.collect::<Vec<_>>()
})
.collect()
}
pub fn db(&self) -> &Db {
&self.0.db
}
@@ -198,8 +150,8 @@ mod tests {
let user1_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10, true);
storage.update_client(user2_token.clone(), 20, true);
storage.update_client(user1_token.clone(), 10);
storage.update_client(user2_token.clone(), 20);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
@@ -222,105 +174,4 @@ mod tests {
assert_eq!(storage.get_client_url_by_machine_id(2, &machine_id), None);
}
#[tokio::test]
async fn list_clients_returns_current_storage_tokens() {
let storage = Storage::new(Db::memory_db().await);
let user1_token = make_storage_token(1, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10, true);
storage.update_client(user2_token.clone(), 20, true);
let tokens = storage.list_clients();
assert_eq!(tokens.len(), 2);
assert!(tokens.iter().any(|token| token.token == user1_token.token));
assert!(tokens.iter().any(|token| token.token == user2_token.token));
storage.remove_client(&user1_token);
let tokens = storage.list_clients();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].token, user2_token.token);
}
#[tokio::test]
async fn pending_client_is_listed_but_not_authorized_for_machine_lookup() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
storage.update_client(token.clone(), 10, false);
assert_eq!(storage.list_clients().len(), 0);
assert_eq!(storage.list_all_clients().len(), 1);
assert_eq!(storage.list_user_clients(1), Vec::<url::Url>::new());
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
assert_eq!(
storage.get_client_url_by_machine_id_with_auth(1, &machine_id, false),
Some(token.client_url.clone())
);
storage.update_client(token.clone(), 11, true);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(token.client_url.clone())
);
storage.update_client(token.clone(), 11, false);
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
assert_eq!(storage.list_clients().len(), 0);
assert_eq!(storage.list_all_clients().len(), 1);
}
#[tokio::test]
async fn stale_client_authorization_update_does_not_replace_newer_client() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let old_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let new_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(old_token.clone(), 10, true);
storage.update_client(new_token.clone(), 20, true);
storage.update_client(old_token, 10, false);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(new_token.client_url)
);
}
#[tokio::test]
async fn pending_client_does_not_replace_authorized_route() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let authorized_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let pending_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(authorized_token.clone(), 10, true);
storage.update_client(pending_token, i64::MAX, false);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(authorized_token.client_url)
);
}
#[tokio::test]
async fn authorized_client_replaces_pending_route_regardless_of_report_time() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let pending_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let authorized_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(pending_token, i64::MAX, false);
storage.update_client(authorized_token.clone(), 10, true);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(authorized_token.client_url)
);
}
}
@@ -1,38 +0,0 @@
//! `SeaORM` Entity, hand-written to match the generated entity style.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "managed_config_revisions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
#[sea_orm(column_type = "Text")]
pub device_id: String,
#[sea_orm(column_type = "Text")]
pub config_revision: String,
pub create_time: DateTimeWithTimeZone,
pub update_time: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
-1
View File
@@ -4,7 +4,6 @@ pub mod prelude;
pub mod groups;
pub mod groups_permissions;
pub mod managed_config_revisions;
pub mod permissions;
pub mod tower_sessions;
pub mod user_running_network_configs;

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