diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..fa1aad29 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,27 @@ +# Per-target rustflags for cross-compilation. +# +# iOS staticlib build (aarch64-apple-ios): +# -C panic=abort — propagate abort strategy to all crates so they match the +# [profile.release] panic = "abort" setting in Cargo.toml. Without this, +# leaf-ffi (which has its own staticlib crate-type) links panic_unwind and +# conflicts with our abort-strategy staticlib. +# IPHONEOS_DEPLOYMENT_TARGET=16.0 is required so the linker doesn't default +# to iOS 10 and produce version-mismatch errors with leaf's netstack-lwip C +# objects (which are compiled against the current Xcode SDK). +[target.aarch64-apple-ios] +rustflags = [] + +[target.aarch64-apple-ios.env] +IPHONEOS_DEPLOYMENT_TARGET = "16.0" + +[target.aarch64-apple-ios-sim] +rustflags = [] + +[target.aarch64-apple-ios-sim.env] +IPHONEOS_DEPLOYMENT_TARGET = "16.0" + +[target.x86_64-apple-ios] +rustflags = [] + +[target.x86_64-apple-ios.env] +IPHONEOS_DEPLOYMENT_TARGET = "16.0" diff --git a/.gitignore b/.gitignore index 1c844db7..68d80382 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ /config.json .DS_Store /SCR-*.png + +# iOS — generated by xcodegen / Xcode, and local signing (never commit team ID) +/ios/MhrvVPN.xcodeproj/ +/ios/build/ +/ios/Local.xcconfig diff --git a/Cargo.lock b/Cargo.lock index e17c71f5..aa6b2e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -122,6 +157,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.5.2" @@ -275,7 +316,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.5.1", "asn1-rs-impl", "displaydoc", "nom", @@ -285,6 +326,22 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -297,6 +354,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "asn1-rs-impl" version = "0.2.0" @@ -466,6 +535,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-socks5" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da2537846e16b96d2972ee52a3b355663872a1a687ce6d57a3b6f6b6a181c89" +dependencies = [ + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "async-task" version = "4.7.1" @@ -543,6 +622,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.21.7" @@ -581,6 +712,46 @@ dependencies = [ "virtue", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -819,6 +990,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -870,6 +1059,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cidr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621" + [[package]] name = "cidr" version = "0.3.2" @@ -879,6 +1074,27 @@ dependencies = [ "serde", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.1" @@ -1078,6 +1294,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1091,9 +1316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc2" version = "3.7.3" @@ -1111,6 +1346,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "daemonize" version = "0.5.0" @@ -1126,13 +1387,68 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der-parser" version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", "displaydoc", "nom", "num-bigint", @@ -1168,6 +1484,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1306,7 +1623,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "web-time", + "web-time 0.2.4", "wgpu", "winapi", "winit", @@ -1342,7 +1659,7 @@ dependencies = [ "log", "thiserror 1.0.69", "type-map", - "web-time", + "web-time 0.2.4", "wgpu", "winit", ] @@ -1361,7 +1678,7 @@ dependencies = [ "raw-window-handle 0.6.2", "serde", "smithay-clipboard", - "web-time", + "web-time 0.2.4", "webbrowser", "winit", ] @@ -1382,6 +1699,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "emath" version = "0.28.1" @@ -1392,6 +1715,18 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1496,6 +1831,15 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etherparse" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d8a704b617484e9d867a0423cd45f7577f008c4068e2e33378f8d3860a6d73" +dependencies = [ + "arrayvec", +] + [[package]] name = "etherparse" version = "0.19.0" @@ -1567,6 +1911,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1780,8 +2130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1791,9 +2143,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1810,6 +2164,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1821,6 +2185,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.13.1" @@ -1970,11 +2340,22 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -2017,6 +2398,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2047,6 +2438,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.6", + "thiserror 1.0.69", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hickory-proto" version = "0.26.0" @@ -2066,6 +2480,33 @@ dependencies = [ "url", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -2076,12 +2517,76 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2251,6 +2756,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2271,12 +2785,34 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "ipstack" version = "1.0.0" @@ -2284,7 +2820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d603c9807158f8054f56c3672c8670096580c3ec1d5bab6f27b2aca89be89117" dependencies = [ "ahash", - "etherparse", + "etherparse 0.19.0", "log", "rand 0.9.4", "serde_json", @@ -2298,6 +2834,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2424,6 +2969,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2447,6 +3001,85 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leaf" +version = "0.1.2" +source = "git+https://github.com/eycorsican/leaf#87f50a02706c6da1182f1b281af27a3306ad181b" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-recursion", + "async-socks5", + "async-trait", + "axum", + "base64 0.22.1", + "bindgen 0.72.1", + "byteorder", + "bytes", + "cc", + "cfb-mode", + "chrono", + "cidr 0.2.3", + "crc32fast", + "digest", + "futures", + "hex", + "hickory-proto 0.24.4", + "hkdf", + "hmac", + "http", + "ipconfig", + "jni 0.21.1", + "lazy_static", + "libc", + "lru", + "lru_time_cache", + "lz_fnv", + "maxminddb", + "md-5", + "memchr", + "memmap2", + "netstack-lwip", + "netstack-smoltcp", + "parking_lot", + "percent-encoding", + "pnet_datalink", + "protobuf", + "protobuf-codegen", + "protoc-bin-vendored", + "quinn", + "rand 0.8.6", + "reality", + "regex", + "ring", + "rustls 0.23.36", + "rustls 0.23.38", + "rustls-pemfile 1.0.4", + "rustls-pemfile 2.2.0", + "serde", + "serde_derive", + "serde_json", + "sha-1", + "sha2", + "sha3", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tun 0.7.22", + "tungstenite", + "url", + "uuid", + "webpki-roots 0.25.4", + "webpki-roots 0.26.11", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2546,6 +3179,33 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lru_time_cache" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" + +[[package]] +name = "lz_fnv" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb1b0dbe51f0976eaa466f4e0bdc11856fe8008aee26f30ccec8de15b28e38" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2555,6 +3215,12 @@ dependencies = [ "libc", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -2564,6 +3230,25 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "memmap2", + "serde", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2636,12 +3321,13 @@ dependencies = [ "http", "httparse", "jni 0.21.1", + "leaf", "libc", "portable-atomic", "rand 0.8.6", "rcgen", - "rustls", - "rustls-pemfile", + "rustls 0.23.38", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -2655,10 +3341,16 @@ dependencies = [ "tun2proxy", "url", "webpki-roots 0.26.11", - "x509-parser", + "x509-parser 0.16.0", "zstd", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2796,6 +3488,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "netstack-lwip" +version = "0.3.4" +source = "git+https://github.com/eycorsican/netstack-lwip?rev=4cc3162#4cc3162c6a968a27d01c943bcb8d38fd0e7311ca" +dependencies = [ + "anyhow", + "bindgen 0.70.1", + "bytes", + "cc", + "futures", + "log", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "netstack-smoltcp" +version = "0.2.0" +source = "git+https://github.com/eycorsican/netstack-smoltcp?branch=pr-16-initialize-unfilled#25e6ad4019dc74119f68a106e3eb5625b0a8c8d4" +dependencies = [ + "etherparse 0.16.0", + "futures", + "rand 0.8.6", + "smoltcp", + "spin", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "nix" version = "0.26.4" @@ -2833,6 +3555,12 @@ dependencies = [ "memoffset 0.9.1", ] +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3137,7 +3865,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", ] [[package]] @@ -3156,6 +3893,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -3271,6 +4014,38 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pnet_base" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5854abf0067ebbd3967f7d45ebc8976ff577ff0c7bd101c4973ae3c70f98fe" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417c0becd1b573f6d544f73671070b039051e5ad819cc64aa96377b536128d00" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "png" version = "0.18.1" @@ -3320,6 +4095,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3394,6 +4181,28 @@ dependencies = [ "toml_edit 0.25.11+spec-1.1.0", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3409,6 +4218,121 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "protobuf" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3018844a02746180074f621e847703737d27d89d7f0721a7a4da317f88b16385" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c15a212b4de05eb8bc989fd066a74c86bd3c04e27d6e86bd7703b806d7734" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06f45f16b522d92336e839b5e40680095a045e36a1e7f742ba682ddc85236772" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf96d872914fcda2b66d66ea3fff2be7c66865d31c7bb2790cff32c0e714880" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "pxfm" version = "0.1.29" @@ -3416,12 +4340,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] -name = "quick-xml" -version = "0.39.2" +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls 0.23.38", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time 1.1.0", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls 0.23.38", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time 1.1.0", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "memchr", + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", ] [[package]] @@ -3543,10 +4522,31 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.16.0", "yasna", ] +[[package]] +name = "reality" +version = "0.1.0" +source = "git+https://github.com/eycorsican/reality-rs.git#4bae9037d40500097911c89ba8b2b9d0c172ff88" +dependencies = [ + "aes-gcm", + "hex", + "hkdf", + "hmac", + "parking_lot", + "rand 0.8.6", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-webpki", + "sha2", + "tokio", + "webpki-roots 1.0.7", + "x25519-dalek", + "x509-parser 0.18.1", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -3740,12 +4740,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "git+https://github.com/eycorsican/reality-rustls.git?branch=reality-rebase-0.23.36#23d1c82a7fe33d98949833888f824d7638604fa7" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3754,6 +4769,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -3769,6 +4793,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time 1.1.0", "zeroize", ] @@ -3859,6 +4884,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3879,6 +4915,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3901,6 +4948,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4041,6 +5098,21 @@ dependencies = [ "serde", ] +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "log", + "managed", +] + [[package]] name = "socket2" version = "0.4.10" @@ -4051,6 +5123,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -4075,6 +5157,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -4108,6 +5199,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -4130,6 +5227,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -4333,8 +5436,20 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.38", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", "tokio", + "tungstenite", ] [[package]] @@ -4345,6 +5460,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -4432,13 +5548,40 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tproxy-config" version = "7.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4b7813b6e0ce19af2e784c14828cc05e80eff850ed4183df7b4a55e6036aeec" dependencies = [ - "cidr", + "cidr 0.3.2", "futures", "libloading 0.9.0", "log", @@ -4466,6 +5609,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -4522,6 +5678,27 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tun" +version = "0.7.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7904c94104239657089d14bccbf23fd6d363e30639ce49af21ef008a445baf97" +dependencies = [ + "bytes", + "cfg-if", + "futures", + "futures-core", + "ipnet", + "libc", + "log", + "nix 0.30.1", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "windows-sys 0.59.0", + "wintun-bindings", +] + [[package]] name = "tun" version = "0.8.7" @@ -4561,7 +5738,7 @@ dependencies = [ "dotenvy", "env_logger", "hashlink", - "hickory-proto", + "hickory-proto 0.26.0", "httparse", "ipstack", "jni 0.22.4", @@ -4576,13 +5753,31 @@ dependencies = [ "tokio", "tokio-util", "tproxy-config", - "tun", + "tun 0.8.7", "udp-stream", "unicase", "url", "windows-service", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4650,6 +5845,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4674,6 +5879,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4686,6 +5897,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5013,6 +6235,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webbrowser" version = "1.2.1" @@ -5029,6 +6261,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5153,6 +6391,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" @@ -5283,6 +6533,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5650,7 +6911,7 @@ dependencies = [ "wayland-protocols 0.31.2", "wayland-protocols-plasma", "web-sys", - "web-time", + "web-time 0.2.4", "windows-sys 0.48.0", "x11-dl", "x11rb", @@ -5842,24 +7103,53 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", "data-encoding", - "der-parser", + "der-parser 9.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.7.1", "ring", "rusticata-macros", "thiserror 1.0.69", "time", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -6045,6 +7335,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 46070609..190d8044 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ path = "src/lib.rs" # `cdylib` lets the Android app dlopen libmhrv_rs.so via System.loadLibrary. # `rlib` keeps the desktop binaries linking normally — same .rlib is used # for `mhrv-rs` and `mhrv-rs-ui` builds on macOS/Linux/Windows. -crate-type = ["rlib", "cdylib"] +# `staticlib` produces libmhrv_rs.a for the iOS NetworkExtension target. +crate-type = ["rlib", "cdylib", "staticlib"] [[bin]] name = "mhrv-rs" @@ -99,6 +100,30 @@ libc = "0.2" jni = { version = "0.21", default-features = false } tun2proxy = { version = "0.7", default-features = false, features = ["udpgw"] } +# iOS-only deps for the NetworkExtension static lib (libmhrv_rs.a). +# +# leaf-ffi is the TUN <-> SOCKS5 bridge for iOS. Unlike Android (which gets a +# raw fd from VpnService), iOS NEPacketTunnelProvider does not expose a public +# raw fd — leaf reads the utun fd we obtain via the SYSPROTO_CONTROL socket +# trick (see PacketTunnelProvider.swift) and handles: +# • userspace TCP/IP stack (smoltcp under the hood) +# • FakeIP/virtual DNS — intercepts DNS, returns 198.18.x.x fake IPs so +# mhrv-rs receives domain names instead of IPs for domain-fronting +# • SOCKS5 outbound → our mhrv-rs proxy +# +# Feature flags: default-ring uses ring for crypto (consistent with the rest +# of this crate); inbound-tun + outbound-socks are the only inbound/outbound +# types we need; config-json lets us build the leaf config as a JSON string +# at runtime rather than requiring a file on disk. +[target.'cfg(target_os = "ios")'.dependencies] +# leaf is not on crates.io — pull from the upstream git workspace. +# We use the `leaf` library crate directly (not `leaf-ffi`) to avoid the +# panic_unwind conflict that arises when leaf-ffi's staticlib/dylib crate-type +# links its own panic runtime against our panic=abort staticlib. +# `default-ring` enables: all-configs (config-json), all-endpoints +# (inbound-tun, outbound-socks), ring crypto — everything we need. +leaf = { git = "https://github.com/eycorsican/leaf", package = "leaf", default-features = false, features = ["default-ring"] } + [dev-dependencies] # Used in mitm tests to sanity-check the cert extensions we emit. x509-parser = "0.16" @@ -113,3 +138,12 @@ codegen-units = 1 lto = true opt-level = 3 strip = true + +# iOS static lib build — panic=unwind lets catch_unwind stop leaf panics from +# killing the whole Network Extension process. Strip disabled so the compact +# unwind tables stay in the .a for the linker to use. +[profile.release-ios] +inherits = "release" +panic = "unwind" +strip = false + diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..82f5bf10 --- /dev/null +++ b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/ios/App/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 00000000..be3b0d36 Binary files /dev/null and b/ios/App/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/ios/App/ContentView.swift b/ios/App/ContentView.swift new file mode 100644 index 00000000..a4ba0dfb --- /dev/null +++ b/ios/App/ContentView.swift @@ -0,0 +1,1282 @@ +import SwiftUI +import NetworkExtension +import UniformTypeIdentifiers +import Compression +import Network + +/// Per-SNI probe result for the SNI pool tester. +enum SniProbeState: Equatable { + case idle + case inFlight + case ok(Int) // handshake latency in ms + case err +} + +/// Default SNI rotation pool — mirrors Android's DEFAULT_SNI_POOL and the Rust +/// DEFAULT_GOOGLE_SNI_POOL. Empty sniHosts in config = let Rust auto-expand these. +let DEFAULT_SNI_POOL: [String] = [ + "www.google.com", + "mail.google.com", + "drive.google.com", + "docs.google.com", + "calendar.google.com", + "accounts.google.com", + "scholar.google.com", + "maps.google.com", + "chat.google.com", + "translate.google.com", + "play.google.com", + "lens.google.com", + "chromewebstore.google.com", +] + +// MARK: — Config model + +/// Structured VPN config — mirrors Android's MhrvConfig / ConfigStore JSON format. +/// The extension accepts both JSON (Android) and TOML; iOS generates JSON for export. +struct VpnConfig { + var mode: String = "full" // full only in UI for now (apps_script | direct | full) + var scriptIds: [String] = [] // bare deployment IDs + var authKey: String = "" + var listenHost: String = "127.0.0.1" + var listenPort: Int = 8085 + var socks5Port: Int = 8086 + var googleIp: String = "216.239.38.120" + var frontDomain: String = "www.google.com" + var sniHosts: [String] = [] // SNI rotation pool; empty = Rust auto-expands defaults + var logLevel: String = "warn" + var verifySsl: Bool = true + var blockQuic: Bool = true + var blockStun: Bool = true + var blockDoh: Bool = true + var tunnelDoh: Bool = true + var coalesceStepMs: Int = 10 // full-mode batch coalescing + var coalesceMaxMs: Int = 1000 + + // MARK: serialise + + /// Android-compatible JSON for sharing and for the extension. + func toJson(pretty: Bool = true) -> String { + var obj: [String: Any] = [ + "mode": mode, + "listen_host": listenHost, + "listen_port": listenPort, + "socks5_port": socks5Port, + "auth_key": authKey, + "google_ip": googleIp, + "front_domain": frontDomain, + "log_level": logLevel, + "verify_ssl": verifySsl, + "block_quic": blockQuic, + "block_stun": blockStun, + "block_doh": blockDoh, + "tunnel_doh": tunnelDoh, + "coalesce_step_ms": coalesceStepMs, + "coalesce_max_ms": coalesceMaxMs, + "fetch_ips_from_api": false, + "max_ips_to_scan": 20, + "scan_batch_size": 100, + ] + let ids = scriptIds.map { Self.extractId($0) }.filter { !$0.isEmpty } + obj["script_ids"] = ids + // Only emit sni_hosts when non-empty (matches Android; empty lets Rust auto-expand). + let sni = sniHosts.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + if !sni.isEmpty { obj["sni_hosts"] = sni } + let opts: JSONSerialization.WritingOptions = pretty ? .prettyPrinted : [] + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: opts), + let str = String(data: data, encoding: .utf8) else { return "{}" } + return str + } + + /// Android-compatible share string: `mhrv-rs://` + URL-safe base64 of the + /// zlib-compressed (RFC 1950) JSON — byte-for-byte decodable by Android's + /// ConfigStore.decode (InflaterInputStream). Falls back to uncompressed + /// base64 if compression fails (still decodable by both sides). + func encode() -> String { + let json = toJson(pretty: false) + let bytes = Data(json.utf8) + let payload = Self.zlibDeflate(bytes) ?? bytes + return "mhrv-rs://" + Self.urlSafeBase64(payload) + } + + private static func urlSafeBase64(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + // MARK: parse + + /// Try to parse raw text — mhrv-rs:// (Android share), JSON, or TOML. + static func from(configText: String) -> VpnConfig? { + let t = configText.trimmingCharacters(in: .whitespacesAndNewlines) + if t.hasPrefix("mhrv-rs://") { return fromMhrvEncoded(t) } + if t.hasPrefix("{") { return fromJson(t) } + return fromToml(t) + } + + /// Decode Android's share format: mhrv-rs:// + URL-safe base64 + zlib-compressed JSON. + private static func fromMhrvEncoded(_ text: String) -> VpnConfig? { + let payload = String(text.dropFirst("mhrv-rs://".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + // URL-safe base64 → standard base64 with padding. + var b64 = payload.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let rem = b64.count % 4 + if rem != 0 { b64 += String(repeating: "=", count: 4 - rem) } + // ignoreUnknownCharacters handles any stray whitespace/newlines in the clipboard. + guard let raw = Data(base64Encoded: b64, options: .ignoreUnknownCharacters) else { return nil } + // Prefer zlib-inflated JSON; fall back to raw UTF-8 (mirrors Android's + // inflateOrRaw — handles uncompressed mhrv-rs:// payloads too). + if let inflated = zlibInflate(raw), + let json = String(data: inflated, encoding: .utf8), + let cfg = fromJson(json) { + return cfg + } + if let json = String(data: raw, encoding: .utf8) { + return fromJson(json) + } + return nil + } + + /// Decompress Java DeflaterOutputStream output (zlib / RFC 1950). + /// Apple's COMPRESSION_ZLIB is actually raw DEFLATE (RFC 1951), so strip the + /// 2-byte zlib header first; the raw decoder ignores the trailing Adler-32. + private static func zlibInflate(_ data: Data) -> Data? { + guard data.count > 6 else { return decompressOnce(data, COMPRESSION_ZLIB) } + if let r = decompressOnce(data.dropFirst(2), COMPRESSION_ZLIB), !r.isEmpty { return r } + // Fallback: maybe it was already raw DEFLATE with no zlib header. + return decompressOnce(data, COMPRESSION_ZLIB) + } + + private static func decompressOnce(_ data: Data, _ algo: compression_algorithm) -> Data? { + guard !data.isEmpty else { return nil } + let bufSize = 256 * 1024 + var outBuf = [UInt8](repeating: 0, count: bufSize) + var written = 0 + data.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) in + guard let src = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return } + outBuf.withUnsafeMutableBufferPointer { dstPtr in + written = compression_decode_buffer( + dstPtr.baseAddress!, bufSize, + src, srcPtr.count, + nil, algo + ) + } + } + guard written > 0 else { return nil } + return Data(outBuf.prefix(written)) + } + + /// Compress to zlib format (RFC 1950) so Android's InflaterInputStream reads + /// it: 2-byte header + raw DEFLATE (Apple COMPRESSION_ZLIB) + 4-byte Adler-32. + private static func zlibDeflate(_ data: Data) -> Data? { + guard !data.isEmpty else { return nil } + let cap = data.count + 4096 + var outBuf = [UInt8](repeating: 0, count: cap) + var written = 0 + data.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) in + guard let src = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return } + outBuf.withUnsafeMutableBufferPointer { dstPtr in + written = compression_encode_buffer( + dstPtr.baseAddress!, cap, + src, srcPtr.count, + nil, COMPRESSION_ZLIB + ) + } + } + guard written > 0 else { return nil } + var out = Data([0x78, 0x9C]) // zlib header: deflate, default level + out.append(contentsOf: outBuf[0.. UInt32 { + var a: UInt32 = 1, b: UInt32 = 0 + let mod: UInt32 = 65521 + for byte in data { + a = (a + UInt32(byte)) % mod + b = (b + a) % mod + } + return (b << 16) | a + } + + static func fromJson(_ json: String) -> VpnConfig? { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + var cfg = VpnConfig() + if let v = obj["mode"] as? String { cfg.mode = v } + if let v = obj["listen_host"] as? String { cfg.listenHost = v } + if let v = obj["listen_port"] as? Int { cfg.listenPort = v } + if let v = obj["socks5_port"] as? Int { cfg.socks5Port = v } + if let v = obj["auth_key"] as? String { cfg.authKey = v } + if let v = obj["google_ip"] as? String { cfg.googleIp = v } + if let v = obj["front_domain"] as? String { cfg.frontDomain = v } + if let v = obj["log_level"] as? String { cfg.logLevel = v } + if let v = obj["verify_ssl"] as? Bool { cfg.verifySsl = v } + if let v = obj["block_quic"] as? Bool { cfg.blockQuic = v } + if let v = obj["block_stun"] as? Bool { cfg.blockStun = v } + if let v = obj["block_doh"] as? Bool { cfg.blockDoh = v } + if let v = obj["tunnel_doh"] as? Bool { cfg.tunnelDoh = v } + if let v = obj["coalesce_step_ms"] as? Int { cfg.coalesceStepMs = v } + if let v = obj["coalesce_max_ms"] as? Int { cfg.coalesceMaxMs = v } + // script_ids: array or single string (both Android formats) + if let arr = obj["script_ids"] as? [String] { + cfg.scriptIds = arr.map { extractId($0) }.filter { !$0.isEmpty } + } else if let arr = obj["script_ids"] as? [Any] { + cfg.scriptIds = arr.compactMap { $0 as? String }.map { extractId($0) }.filter { !$0.isEmpty } + } else if let s = obj["script_id"] as? String { + let id = extractId(s); if !id.isEmpty { cfg.scriptIds = [id] } + } + if let arr = obj["sni_hosts"] as? [Any] { + cfg.sniHosts = arr.compactMap { $0 as? String } + .map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } + return cfg + } + + /// Minimal TOML parser for the fields we care about (no full parser needed). + static func fromToml(_ toml: String) -> VpnConfig? { + var cfg = VpnConfig() + var section = "" + var found = false + for raw in toml.components(separatedBy: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("[") { + section = line + continue + } + let parts = line.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = parts[0].trimmingCharacters(in: .whitespaces) + let raw = parts[1].trimmingCharacters(in: .whitespaces) + let val = raw.hasPrefix("\"") && raw.hasSuffix("\"") + ? String(raw.dropFirst().dropLast()) : raw + + let inRelay = section.contains("relay") + let inNetwork = section.contains("network") + let inLog = section.contains("logging") + let flat = section.isEmpty + + if inRelay || flat { + switch key { + case "mode": cfg.mode = val; found = true + case "auth_key": cfg.authKey = val; found = true + case "script_id": + // single value or inline array ["A","B"] + if val.hasPrefix("[") { + let ids = val.dropFirst().dropLast() + .split(separator: ",") + .map { $0.trimmingCharacters(in: .init(charactersIn: " \"")) } + .filter { !$0.isEmpty } + cfg.scriptIds = ids.map { extractId($0) }.filter { !$0.isEmpty } + } else { + let id = extractId(val); if !id.isEmpty { cfg.scriptIds = [id] } + } + found = true + default: break + } + } + if inNetwork || flat { + switch key { + case "google_ip": cfg.googleIp = val + case "front_domain": cfg.frontDomain = val + case "listen_host": cfg.listenHost = val + case "listen_port": cfg.listenPort = Int(val) ?? cfg.listenPort + case "socks5_port": cfg.socks5Port = Int(val) ?? cfg.socks5Port + case "block_quic": cfg.blockQuic = val == "true" + case "block_stun": cfg.blockStun = val == "true" + case "block_doh": cfg.blockDoh = val == "true" + case "tunnel_doh": cfg.tunnelDoh = val == "true" + case "verify_ssl": cfg.verifySsl = val == "true" + default: break + } + } + if inLog || flat { + if key == "log_level" { cfg.logLevel = val } + } + } + return found ? cfg : nil + } + + /// Extract bare deployment ID from either a full URL or bare ID. + static func extractId(_ input: String) -> String { + var s = input.trimmingCharacters(in: .whitespaces) + if s.isEmpty { return s } + let marker = "/macros/s/" + if let r = s.range(of: marker) { s = String(s[r.upperBound...]) } + if let i = s.firstIndex(of: "/") { s = String(s[.. { + if !vpn.config.sniHosts.isEmpty { return Set(vpn.config.sniHosts) } + var s = Set(DEFAULT_SNI_POOL) + let fd = vpn.config.frontDomain.trimmingCharacters(in: .whitespaces) + if !fd.isEmpty { s.insert(fd) } + return s + } + + var body: some View { + CollapsibleCard(title: "SNI Pool", expanded: false) { + VStack(alignment: .leading, spacing: 10) { + Text("Hostnames rotated as TLS SNI for domain fronting. Leave all enabled to let the engine pick; disable any that your network blocks.") + .font(.caption).foregroundStyle(.secondary) + + ForEach(displayed, id: \.self) { sni in + HStack(spacing: 8) { + Text(sni).font(.system(.caption, design: .monospaced)) + Spacer() + probeView(sni) + Toggle("", isOn: Binding( + get: { enabledSet.contains(sni) }, + set: { on in toggle(sni, on: on) } + )) + .labelsHidden() + } + } + + Button { + for sni in displayed { vpn.testSni(sni) } + } label: { + Label("Test all", systemImage: "bolt.horizontal") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .font(.caption) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + TextField("Add custom SNI host(s)", text: $custom, axis: .vertical) + .font(.caption) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(1...4) + .padding(8) + .background(Color(.systemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Button { + addCustom() + } label: { + Label("Add SNI", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(custom.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + @ViewBuilder + private func probeView(_ sni: String) -> some View { + switch vpn.sniProbe[sni] ?? .idle { + case .idle: + Button { vpn.testSni(sni) } label: { + Image(systemName: "bolt.horizontal").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + case .inFlight: + ProgressView().controlSize(.small) + case .ok(let ms): + Button { vpn.testSni(sni) } label: { + HStack(spacing: 3) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text("\(ms)ms").font(.caption2).foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + case .err: + Button { vpn.testSni(sni) } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + + private func toggle(_ sni: String, on: Bool) { + // Materialize the full enabled set first (empty sniHosts means "all defaults"). + var current = vpn.config.sniHosts.isEmpty ? Array(enabledSet) : vpn.config.sniHosts + if on { + if !current.contains(sni) { current.append(sni) } + } else { + current.removeAll { $0 == sni } + } + vpn.config.sniHosts = current + vpn.save() + } + + private func addCustom() { + let tokens = custom + .components(separatedBy: CharacterSet(charactersIn: " \n\t,;")) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { return } + var base = vpn.config.sniHosts.isEmpty ? Array(enabledSet) : vpn.config.sniHosts + for t in tokens where !base.contains(t) { base.append(t) } + vpn.config.sniHosts = base + vpn.save() + custom = "" + } +} + +// MARK: — Advanced section + +private struct AdvancedSection: View { + @ObservedObject var vpn: VpnManager + + private let logLevels = ["trace", "debug", "info", "warn", "error", "off"] + + var body: some View { + CollapsibleCard(title: "Advanced", expanded: false) { + VStack(alignment: .leading, spacing: 12) { + // Mode — locked to Full tunnel for now (other modes land in a later PR). + VStack(alignment: .leading, spacing: 4) { + Text("Mode").font(.subheadline).foregroundStyle(.secondary) + HStack { + Text("Full tunnel").font(.body) + Spacer() + Text("locked").font(.caption2).foregroundStyle(.secondary) + } + Text("All traffic tunneled through Apps Script + remote node. No cert needed. Other modes coming soon.") + .font(.caption).foregroundStyle(.secondary) + } + + Divider() + + // Log level + VStack(alignment: .leading, spacing: 4) { + Text("Log Level").font(.subheadline).foregroundStyle(.secondary) + Picker("Log Level", selection: Binding( + get: { vpn.config.logLevel }, + set: { vpn.config.logLevel = $0; vpn.save() } + )) { + ForEach(logLevels, id: \.self) { Text($0) } + } + .pickerStyle(.segmented) + } + + Divider() + + // Toggles + Toggle("Block QUIC (UDP/443)", isOn: Binding( + get: { vpn.config.blockQuic }, + set: { vpn.config.blockQuic = $0; vpn.save() } + )) + Text("Drops QUIC so browsers fall back to HTTPS/TCP — prevents TCP-over-UDP meltdown.") + .font(.caption).foregroundStyle(.secondary) + + Divider() + Toggle("Block STUN/TURN", isOn: Binding( + get: { vpn.config.blockStun }, + set: { vpn.config.blockStun = $0; vpn.save() } + )) + Text("Drops STUN/TURN (3478/5349/19302) so WebRTC falls back to TCP.") + .font(.caption).foregroundStyle(.secondary) + + Divider() + Toggle("Block DoH", isOn: Binding( + get: { vpn.config.blockDoh }, + set: { vpn.config.blockDoh = $0; vpn.save() } + )) + Text("Reject browser DoH — forces system DNS via leaf FakeIP (instant, no relay round-trip).") + .font(.caption).foregroundStyle(.secondary) + + Divider() + Toggle("Verify TLS", isOn: Binding( + get: { vpn.config.verifySsl }, + set: { vpn.config.verifySsl = $0; vpn.save() } + )) + + Divider() + + // Full-mode batch coalescing. + VStack(alignment: .leading, spacing: 4) { + Text("Batch Coalescing").font(.subheadline).foregroundStyle(.secondary) + HStack(spacing: 10) { + LabeledField(label: "Window (ms)") { + TextField("10", value: Binding( + get: { vpn.config.coalesceStepMs }, + set: { vpn.config.coalesceStepMs = max(0, $0); vpn.save() } + ), format: .number) + .keyboardType(.numberPad) + } + LabeledField(label: "Max (ms)") { + TextField("1000", value: Binding( + get: { vpn.config.coalesceMaxMs }, + set: { vpn.config.coalesceMaxMs = max(0, $0); vpn.save() } + ), format: .number) + .keyboardType(.numberPad) + } + } + Text("How long the tunnel waits to batch outbound requests to Apps Script. Lower = snappier; higher = fewer round-trips (better throughput). 0 = compiled default.") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: — Live logs section + +private struct LogsSection: View { + @ObservedObject var vpn: VpnManager + @State private var expanded = false + + var body: some View { + CollapsibleCard(title: "Logs", expanded: false) { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("\(vpn.logs.isEmpty ? 0 : vpn.logs.components(separatedBy: "\n").count) lines") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Copy") { + UIPasteboard.general.string = vpn.logs + } + .font(.caption) + .disabled(vpn.logs.isEmpty) + Button("Clear") { vpn.logs = "" } + .font(.caption) + } + if let cp = vpn.lastCheckpoint { + Text("Checkpoint: \(cp)").font(.caption2).foregroundStyle(.secondary) + } + ScrollView { + Text(vpn.logs.isEmpty ? "(no logs yet)" : vpn.logs) + .font(.system(.caption2, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 240) + .background(Color(.systemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } +} + +// MARK: — Shared UI helpers + +private struct CollapsibleCard: View { + let title: String + let expanded: Bool + @ViewBuilder let content: () -> Content + @State private var open: Bool + + init(title: String, expanded: Bool, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.expanded = expanded + self.content = content + self._open = State(initialValue: expanded) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { open.toggle() } + } label: { + HStack { + Text(title).font(.headline) + Spacer() + Image(systemName: open ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding() + + if open { + Divider() + VStack(alignment: .leading, spacing: 10) { + content() + } + .padding() + } + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct LabeledField: View { + let label: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + content() + .padding(8) + .background(Color(.systemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// UIActivityViewController wrapper for the share sheet. +private struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + func updateUIViewController(_ vc: UIActivityViewController, context: Context) {} +} + +// MARK: — VPN Manager + +@MainActor +class VpnManager: ObservableObject { + @Published var isConnected = false + @Published var statusLabel = "Not connected" + @Published var logs = "" + @Published var importedAlert = false + @Published var importErrorAlert = false + @Published var importError = "" + @Published var lastCheckpoint: String? + @Published var detectingIp = false + @Published var detectIpMessage: String? + @Published var sniProbe: [String: SniProbeState] = [:] + + /// Structured config — source of truth for the UI. + @Published var config = VpnConfig() + + private let groupId = "group.com.therealaleph.mhrv" + private let tunnelId = "com.therealaleph.mhrv.tunnel" + private var manager: NETunnelProviderManager? + private var statusObserver: Any? + private var logTimer: Timer? + private var checkpointTimer: Timer? + + // MARK: lifecycle + + func load() { + let ud = UserDefaults(suiteName: groupId) + // Try loading structured JSON first; fall back to raw text. + if let raw = ud?.string(forKey: "mhrv_config"), !raw.isEmpty { + config = VpnConfig.from(configText: raw) ?? defaultConfig() + } else { + config = defaultConfig() + } + // The UI only supports full-tunnel mode right now; other modes ship later. + config.mode = "full" + save() + startLogPolling() + startCheckpointPolling() + loadManager() + } + + func save() { + let json = config.toJson() + UserDefaults(suiteName: groupId)?.set(json, forKey: "mhrv_config") + } + + func toggle() { + isConnected ? disconnect() : connect() + } + + // MARK: Google IP auto-detect + + /// Resolve the front domain to its current IPv4 edge and store it as + /// google_ip — mirrors Android's NetworkDetect.resolveGoogleIp. Runs before + /// the tunnel is up so the system resolver is used. + func autoDetectGoogleIp() { + let host = config.frontDomain.isEmpty ? "www.google.com" : config.frontDomain + detectingIp = true + detectIpMessage = nil + Task.detached { + let ip = Self.resolveIPv4(host) + await MainActor.run { + self.detectingIp = false + if let ip { + self.config.googleIp = ip + self.save() + self.detectIpMessage = "Set Google IP to \(ip) (from \(host))." + } else { + self.detectIpMessage = "Couldn't resolve \(host). Check connectivity and try again." + } + } + } + } + + // MARK: SNI probe + + /// Probe one SNI host by completing a TLS handshake to the configured Google + /// edge IP with that SNI, measuring latency. Cert trust is bypassed — this + /// measures DPI passability + reachability, not certificate validity. + func testSni(_ host: String) { + let ip = config.googleIp.trimmingCharacters(in: .whitespaces) + guard !ip.isEmpty else { sniProbe[host] = .err; return } + sniProbe[host] = .inFlight + + let tls = NWProtocolTLS.Options() + sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host) + sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { _, _, complete in + complete(true) // accept any cert — we only care that the handshake completes + }, DispatchQueue.global()) + + let params = NWParameters(tls: tls) + let conn = NWConnection(host: NWEndpoint.Host(ip), port: 443, using: params) + let start = Date() + var finished = false + let finish: (SniProbeState) -> Void = { [weak self] state in + if finished { return } + finished = true + conn.cancel() + Task { @MainActor in self?.sniProbe[host] = state } + } + conn.stateUpdateHandler = { state in + switch state { + case .ready: finish(.ok(Int(Date().timeIntervalSince(start) * 1000))) + case .failed, .cancelled: finish(.err) + default: break + } + } + conn.start(queue: .global()) + DispatchQueue.global().asyncAfter(deadline: .now() + 6) { finish(.err) } + } + + /// Blocking DNS resolution to the first IPv4 address. Call off the main actor. + nonisolated private static func resolveIPv4(_ host: String) -> String? { + var hints = addrinfo(ai_flags: 0, ai_family: AF_INET, ai_socktype: SOCK_STREAM, + ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result else { return nil } + defer { freeaddrinfo(result) } + var node = Optional(first) + while let n = node { + if let sa = n.pointee.ai_addr { + var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + let sin = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + var addr = sin.sin_addr + if inet_ntop(AF_INET, &addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil { + return String(cString: buf) + } + } + node = n.pointee.ai_next + } + return nil + } + + // MARK: import + + /// Accept raw text from clipboard or deep link — mhrv-rs://, JSON, or TOML. + func applyConfigText(_ text: String) { + let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + showImportError("Clipboard is empty.") + return + } + guard let parsed = VpnConfig.from(configText: cleaned) else { + showImportError("Unrecognised config format.\nExpected JSON, TOML, or a mhrv-rs:// share link.") + return + } + config = parsed + save() + importedAlert = true + } + + /// Handle deep links: + /// mhrvvpn://import?config= + /// mhrvvpn://import?url= + func handleImport(url: URL) { + guard url.scheme?.lowercased() == "mhrvvpn", + url.host?.lowercased() == "import" else { return } + + let params = Dictionary( + uniqueKeysWithValues: (URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + + if let b64 = params["config"] { + guard let data = Data(base64Encoded: b64, options: .ignoreUnknownCharacters), + let text = String(data: data, encoding: .utf8) else { + showImportError("Invalid base64 payload.") + return + } + applyConfigText(text) + } else if let rawUrl = params["url"], let fetchUrl = URL(string: rawUrl) { + Task { + do { + let (data, _) = try await URLSession.shared.data(from: fetchUrl) + guard let text = String(data: data, encoding: .utf8) else { + await MainActor.run { self.showImportError("Config is not valid UTF-8.") } + return + } + await MainActor.run { self.applyConfigText(text) } + } catch { + await MainActor.run { self.showImportError(error.localizedDescription) } + } + } + } else { + showImportError("Missing 'config' or 'url' parameter.") + } + } + + private func showImportError(_ msg: String) { + importError = msg + importErrorAlert = true + } + + // MARK: private — tunnel management + + private func loadManager() { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, _ in + guard let self else { return } + if let existing = managers?.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == self.tunnelId + }) { + self.manager = existing + } else { + self.manager = self.buildManager() + } + self.observeStatus() + self.updateStatus() + } + } + + private func buildManager() -> NETunnelProviderManager { + let m = NETunnelProviderManager() + m.localizedDescription = "MhrvVPN" + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = tunnelId + proto.serverAddress = "mhrv-relay" + m.protocolConfiguration = proto + m.isEnabled = true + return m + } + + private func connect() { + guard let m = manager else { return } + save() // flush latest config to shared UserDefaults before starting + let ud = UserDefaults(suiteName: groupId) + ud?.synchronize() + m.isEnabled = true + m.saveToPreferences { [weak self] error in + if let error { + self?.statusLabel = "Save failed: \(error.localizedDescription)" + return + } + do { + try m.connection.startVPNTunnel() + } catch { + self?.statusLabel = "Start failed: \(error.localizedDescription)" + } + } + } + + private func disconnect() { + manager?.connection.stopVPNTunnel() + } + + private func observeStatus() { + statusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: manager?.connection, + queue: .main + ) { [weak self] _ in self?.updateStatus() } + } + + private func updateStatus() { + let s = manager?.connection.status ?? .invalid + isConnected = (s == .connected) + statusLabel = s.displayName + } + + // MARK: log polling + + private func startLogPolling() { + let nc = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterAddObserver(nc, Unmanaged.passRetained(self).toOpaque(), + { _, observer, _, _, _ in + let mgr = Unmanaged.fromOpaque(observer!).takeUnretainedValue() + Task { @MainActor in mgr.pollLogs() } + }, + "com.therealaleph.mhrv.newLogs" as CFString, + nil, .deliverImmediately + ) + pollLogs() + // Fallback polling every 2 s in case Darwin notification is missed. + logTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.pollLogs() } + } + } + + private func pollLogs() { + let ud = UserDefaults(suiteName: groupId) + if let fresh = ud?.string(forKey: "mhrv_logs"), !fresh.isEmpty { + logs = fresh + } + } + + // Poll the tunnel_checkpoint key so the UI shows progress even before logs flush. + private func startCheckpointPolling() { + checkpointTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + let ud = UserDefaults(suiteName: self?.groupId ?? "") + self?.lastCheckpoint = ud?.string(forKey: "tunnel_checkpoint") + } + } + } + + // MARK: defaults + + private func defaultConfig() -> VpnConfig { + VpnConfig() + } +} + +// MARK: — Helpers + +extension UIApplication { + /// Resign first responder on whatever is focused — hides the keyboard. + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + +extension NEVPNStatus { + var displayName: String { + switch self { + case .invalid: return "Not configured" + case .disconnected: return "Disconnected" + case .connecting: return "Connecting…" + case .connected: return "Connected" + case .reasserting: return "Reconnecting…" + case .disconnecting: return "Disconnecting…" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/App/Info.plist b/ios/App/Info.plist new file mode 100644 index 00000000..3f52166f --- /dev/null +++ b/ios/App/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleDisplayName + MhrvVPN + CFBundleName + MhrvVPN + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + NSLocalNetworkUsageDescription + MhrvVPN uses a local proxy to route traffic through the VPN tunnel. + + CFBundleURLTypes + + + CFBundleURLName + com.therealaleph.mhrv + CFBundleURLSchemes + + mhrvvpn + + + + + diff --git a/ios/App/MhrvApp.swift b/ios/App/MhrvApp.swift new file mode 100644 index 00000000..139e2fd9 --- /dev/null +++ b/ios/App/MhrvApp.swift @@ -0,0 +1,11 @@ +import SwiftUI +import NetworkExtension + +@main +struct MhrvApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/App/MhrvVPN.entitlements b/ios/App/MhrvVPN.entitlements new file mode 100644 index 00000000..6ffc185b --- /dev/null +++ b/ios/App/MhrvVPN.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.com.therealaleph.mhrv + + + diff --git a/ios/Local.xcconfig.example b/ios/Local.xcconfig.example new file mode 100644 index 00000000..f5ec408e --- /dev/null +++ b/ios/Local.xcconfig.example @@ -0,0 +1,6 @@ +// Signing config template. Copy this to `Local.xcconfig` (which is gitignored) +// and set your own Apple Developer team ID, then run `xcodegen generate`. +// +// cp Local.xcconfig.example Local.xcconfig +// +DEVELOPMENT_TEAM = YOUR_TEAM_ID diff --git a/ios/NetworkExtension/BridgingHeader.h b/ios/NetworkExtension/BridgingHeader.h new file mode 100644 index 00000000..81c09127 --- /dev/null +++ b/ios/NetworkExtension/BridgingHeader.h @@ -0,0 +1 @@ +#include "mhrv_rs.h" diff --git a/ios/NetworkExtension/Info.plist b/ios/NetworkExtension/Info.plist new file mode 100644 index 00000000..bd6b3089 --- /dev/null +++ b/ios/NetworkExtension/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + MhrvTunnel + CFBundleDisplayName + MhrvTunnel + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + XPC! + CFBundleInfoDictionaryVersion + 6.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/ios/NetworkExtension/MhrvTunnel.entitlements b/ios/NetworkExtension/MhrvTunnel.entitlements new file mode 100644 index 00000000..e118223e --- /dev/null +++ b/ios/NetworkExtension/MhrvTunnel.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.com.therealaleph.mhrv + + + diff --git a/ios/NetworkExtension/PacketTunnelProvider.swift b/ios/NetworkExtension/PacketTunnelProvider.swift new file mode 100644 index 00000000..e3965940 --- /dev/null +++ b/ios/NetworkExtension/PacketTunnelProvider.swift @@ -0,0 +1,280 @@ +import NetworkExtension +import os.log + +// PacketTunnelProvider — full-tunnel VPN using mhrv-rs + leaf/FakeIP. +// +// Traffic flow (full tunnel, all traffic): +// App → NEPacketTunnelProvider (this class) +// → leaf TUN inbound (FakeIP DNS: intercepts DNS, maps domains→198.18.x.x) +// → leaf SOCKS5 outbound → mhrv-rs ProxyServer (loopback) +// → mhrv-rs tunnel client (DPI bypass via domain fronting) +// → Internet +// +// Build: cargo build --target aarch64-apple-ios --release +// Link: libmhrv_rs.a in the NetworkExtension target's "Link Binary With Libraries" +// Bridge: add mhrv_rs.h to the bridging header (or module map) + +class PacketTunnelProvider: NEPacketTunnelProvider { + + private let log = OSLog(subsystem: "com.therealaleph.mhrv", category: "tunnel") + private var sessionId: UInt64 = 0 + private var logDrainTimer: Timer? + + // MARK: — Lifecycle + + override func startTunnel( + options: [String: NSObject]?, + completionHandler: @escaping (Error?) -> Void + ) { + os_log("startTunnel called", log: log, type: .info) + + let groupId = "group.com.therealaleph.mhrv" + let defaults = UserDefaults(suiteName: groupId) + + // Checkpoint helper — survives an extension crash (written to + // UserDefaults before each step so the host app can show "last step"). + func checkpoint(_ msg: String) { + os_log("%{public}@", log: self.log, type: .info, msg) + defaults?.set(msg, forKey: "tunnel_checkpoint") + defaults?.synchronize() + } + + // 1. Data dir — App Group container shared with the host app. + checkpoint("step1: setting data dir") + let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupId) + let dataDir = groupURL?.path ?? NSTemporaryDirectory() + mhrv_set_data_dir(dataDir) + + // 2. Config. + checkpoint("step2: loading config") + guard let configStr = defaults?.string(forKey: "mhrv_config"), !configStr.isEmpty else { + checkpoint("ERROR: no mhrv_config in UserDefaults") + completionHandler(TunnelError.missingConfig) + return + } + checkpoint("step3: config loaded (\(configStr.count) bytes)") + + // 3. Network settings (full tunnel). + let settings = buildNetworkSettings() + checkpoint("step4: calling setTunnelNetworkSettings") + + setTunnelNetworkSettings(settings) { [weak self] error in + guard let self = self else { return } + if let error = error { + self.checkpoint("ERROR: setTunnelNetworkSettings: \(error.localizedDescription)", defaults: defaults) + completionHandler(error) + return + } + self.checkpoint("step5: network settings applied", defaults: defaults) + + // 4. Obtain the utun fd. KVC works on most iOS versions, but the + // keypath has broken on newer ones (returns nil), so fall back to + // scanning for the utun control socket — the WireGuard approach. + guard let tunFd = self.resolveTunFd(defaults: defaults), tunFd >= 0 else { + self.checkpoint("ERROR: could not resolve utun fd (KVC + scan failed)", defaults: defaults) + completionHandler(TunnelError.noTunFd) + return + } + os_log("utun fd = %d", log: self.log, type: .info, tunFd) + + // 5. Start mhrv-rs + leaf. + self.checkpoint("step7: calling mhrv_start tunFd=\(tunFd)", defaults: defaults) + let sid = mhrv_start(configStr, tunFd) + self.checkpoint("step8: mhrv_start returned sid=\(sid)", defaults: defaults) + guard sid != 0 else { + // Drain logs immediately so the failure reason is visible. + self.drainLogs() + self.checkpoint("ERROR: mhrv_start returned 0", defaults: defaults) + completionHandler(TunnelError.startFailed) + return + } + self.sessionId = sid + os_log("mhrv_start ok, session=%llu", log: self.log, type: .info, sid) + + // Drain any startup logs produced by Rust before the timer fires. + self.drainLogs() + + // 6. Periodic log drain. + self.startLogDrain() + self.checkpoint("step9: tunnel running sid=\(sid)", defaults: defaults) + completionHandler(nil) + } + } + + private func checkpoint(_ msg: String, defaults: UserDefaults?) { + os_log("%{public}@", log: log, type: .info, msg) + defaults?.set(msg, forKey: "tunnel_checkpoint") + defaults?.synchronize() + } + + // MARK: — utun fd resolution + + /// Resolve the utun file descriptor. Tries the KVC keypath first (works on + /// most iOS versions); if that returns nil/garbage — as seen on newer iOS — + /// falls back to scanning open fds for the utun control socket. + private func resolveTunFd(defaults: UserDefaults?) -> Int32? { + let rawFd = packetFlow.value(forKeyPath: "socket.fileDescriptor") + checkpoint("step6: KVC rawFd=\(String(describing: rawFd))", defaults: defaults) + if let n = rawFd as? NSNumber, n.int32Value >= 0 { return n.int32Value } + if let i = rawFd as? Int32, i >= 0 { return i } + let scanned = Self.findUtunFd() + checkpoint("step6b: fd scan=\(String(describing: scanned))", defaults: defaults) + return scanned + } + + /// Scan open fds for the one backing a "utun*" interface and return it. + /// Asks each fd for its utun interface name via getsockopt; non-utun and + /// non-socket fds simply fail the call and are skipped. This is the approach + /// WireGuard uses on iOS — it relies only on public symbols (the kernel + /// control structs `sockaddr_ctl`/`ctl_info` are not exposed in the iOS SDK). + private static func findUtunFd() -> Int32? { + let sysprotoControl: Int32 = 2 // SYSPROTO_CONTROL (not public on iOS) + let utunOptIfname: Int32 = 2 // UTUN_OPT_IFNAME (not public on iOS) + var nameBuf = [CChar](repeating: 0, count: 256) + for fd: Int32 in 0...1024 { + var len = socklen_t(nameBuf.count) + let ret = getsockopt(fd, sysprotoControl, utunOptIfname, &nameBuf, &len) + if ret == 0, String(cString: nameBuf).hasPrefix("utun") { + return fd + } + } + return nil + } + + override func stopTunnel( + with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void + ) { + os_log("stopTunnel reason=%d", log: log, type: .info, reason.rawValue) + let ud = UserDefaults(suiteName: "group.com.therealaleph.mhrv") + ud?.set("stopTunnel reason=\(reason.rawValue)", forKey: "tunnel_checkpoint") + ud?.synchronize() + logDrainTimer?.invalidate() + logDrainTimer = nil + // Final log drain so the host app can see what caused the stop. + drainLogs() + + if sessionId != 0 { + mhrv_stop(sessionId) + sessionId = 0 + } + completionHandler() + } + + // MARK: — Network settings (full tunnel) + + private func buildNetworkSettings() -> NEPacketTunnelNetworkSettings { + // Remote address is a dummy — actual traffic goes through loopback SOCKS5. + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "240.0.0.1") + + // IPv4: TUN address in the FakeIP range leaf uses (198.18.0.1/15). + // The /15 covers 198.18.0.0–198.19.255.255 — leaf's entire FakeIP pool. + let ipv4 = NEIPv4Settings(addresses: ["198.18.0.1"], subnetMasks: ["255.254.0.0"]) + // Full tunnel: default route through the TUN interface. + ipv4.includedRoutes = [NEIPv4Route.default()] + // Exclude RFC-1918 ranges so local LAN traffic bypasses the tunnel. + ipv4.excludedRoutes = [ + NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"), + NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"), + NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"), + ] + settings.ipv4Settings = ipv4 + + // IPv6: minimal — exclude everything except loopback so IPv6 falls back + // to IPv4 (the FakeIP pool is IPv4-only in leaf's current implementation). + let ipv6 = NEIPv6Settings(addresses: ["fd00::1"], networkPrefixLengths: [64]) + ipv6.includedRoutes = [NEIPv6Route.default()] + ipv6.excludedRoutes = [ + NEIPv6Route(destinationAddress: "fe80::", networkPrefixLength: 10), + NEIPv6Route(destinationAddress: "fc00::", networkPrefixLength: 7), + NEIPv6Route(destinationAddress: "::1", networkPrefixLength: 128), + ] + settings.ipv6Settings = ipv6 + + // DNS: point all queries at leaf's FakeIP DNS listener on the TUN address. + // leaf intercepts these, returns fake 198.18.x.x IPs, and maps them back + // to real hostnames when the TCP connection arrives — giving mhrv-rs the + // domain name it needs for domain-fronting decisions. + settings.dnsSettings = NEDNSSettings(servers: ["198.18.0.1"]) + + settings.mtu = 1500 + return settings + } + + // MARK: — Log drain + + private func startLogDrain() { + logDrainTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.drainLogs() + } + RunLoop.main.add(logDrainTimer!, forMode: .common) + } + + private func drainLogs() { + guard let ptr = mhrv_drain_logs() else { return } + defer { mhrv_free_string(ptr) } + let s = String(cString: ptr) + guard !s.isEmpty else { return } + // Post to the host app via Darwin notifications so it can display them + // in a live log panel without any IPC round-trip. + let defaults = UserDefaults(suiteName: "group.com.therealaleph.mhrv") + let existing = defaults?.string(forKey: "mhrv_logs") ?? "" + let combined = existing.isEmpty ? s : existing + "\n" + s + // Keep the stored log bounded to ~50 KB. + let capped = combined.count > 50_000 + ? String(combined.suffix(50_000)) + : combined + defaults?.set(capped, forKey: "mhrv_logs") + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName("com.therealaleph.mhrv.newLogs" as CFString), + nil, nil, true + ) + } + + // MARK: — App messages (host app → extension) + + override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { + guard let msg = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any], + let action = msg["action"] as? String else { + completionHandler?(nil) + return + } + switch action { + case "exportCa": + handleExportCa(completionHandler: completionHandler) + case "version": + let v = String(cString: mhrv_version()) + let data = try? JSONSerialization.data(withJSONObject: ["version": v]) + completionHandler?(data) + default: + completionHandler?(nil) + } + } + + private func handleExportCa(completionHandler: ((Data?) -> Void)?) { + let tmp = NSTemporaryDirectory() + "mhrv_ca.pem" + let ok = mhrv_export_ca(tmp) + var resp: [String: Any] = ["ok": ok] + if ok { resp["path"] = tmp } + let data = try? JSONSerialization.data(withJSONObject: resp) + completionHandler?(data) + } +} + +// MARK: — Errors + +enum TunnelError: LocalizedError { + case missingConfig + case noTunFd + case startFailed + + var errorDescription: String? { + switch self { + case .missingConfig: return "No VPN config found. Open the app and configure it first." + case .noTunFd: return "Could not obtain tunnel file descriptor." + case .startFailed: return "VPN engine failed to start. Check the app logs." + } + } +} diff --git a/ios/NetworkExtension/mhrv_rs.h b/ios/NetworkExtension/mhrv_rs.h new file mode 100644 index 00000000..371d1d1a --- /dev/null +++ b/ios/NetworkExtension/mhrv_rs.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include + +/// Set the data directory for certificate storage. +/// Call once before mhrv_start, with the App Group container path. +void mhrv_set_data_dir(const char *path); + +/// Start the VPN: mhrv-rs SOCKS5 proxy + leaf TUN/FakeIP bridge. +/// +/// config_json mhrv-rs config as JSON or TOML string. +/// listen_host should be "127.0.0.1" (loopback only). +/// tun_fd File descriptor for the utun device, obtained via: +/// packetFlow.value(forKeyPath: "socket.fileDescriptor") +/// +/// Returns a nonzero session handle on success, 0 on failure. +uint64_t mhrv_start(const char *config_json, int32_t tun_fd); + +/// Stop the session returned by mhrv_start. +/// Returns true if the session was found and stopped. +bool mhrv_stop(uint64_t session_id); + +/// Drain the in-memory log ring as a '\n'-joined C string. +/// The caller MUST free the returned pointer with mhrv_free_string. +char *mhrv_drain_logs(void); + +/// Free a string returned by mhrv_drain_logs. +void mhrv_free_string(char *s); + +/// Return the crate version string. The pointer is static — do NOT free. +const char *mhrv_version(void); + +/// Copy the MITM CA certificate to dest_path. +/// Returns true on success. +bool mhrv_export_ca(const char *dest_path); diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000..3500d1cd --- /dev/null +++ b/ios/README.md @@ -0,0 +1,126 @@ +# MhrvVPN — iOS + +Full-tunnel VPN client. The Network Extension (`MhrvTunnel`) links the Rust core +(`libmhrv_rs.a`) and runs leaf (TUN/FakeIP) → SOCKS5 → mhrv-rs relay. + +## Prerequisites + +- Xcode (full install, not just Command Line Tools). +- Rust + iOS targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios` +- `xcodegen` (`brew install xcodegen`). +- An Apple Developer account in the team that owns the App ID / App Group. + +## Signing setup (one-time) + +The signing team lives in a local, gitignored `Local.xcconfig` so no team ID is +ever committed. Create it from the template and set your Apple Developer team: + +```sh +cd ios +cp Local.xcconfig.example Local.xcconfig +# edit Local.xcconfig: DEVELOPMENT_TEAM = +``` + +`xcodegen generate` reads it. (The generated `MhrvVPN.xcodeproj` and `build/` +are also gitignored.) + +## Build & run (device) + +```sh +cd ios +xcodegen generate # regenerate MhrvVPN.xcodeproj after editing project.yml +open MhrvVPN.xcodeproj +``` + +Then in Xcode: select the **MhrvVPN** scheme + your device, and **Product → Run** (⌘R). + +The Rust static lib is built automatically by the **"Build Rust static lib"** +pre-build script. It uses: + +```sh +cargo rustc --profile release-ios --target aarch64-apple-ios --lib --crate-type staticlib +``` + +> Important: do **not** switch this back to `cargo build --lib`. The crate is +> `crate-type = ["cdylib","rlib","staticlib"]`, and `cargo build` also links the +> cdylib, which fails on iOS (`Undefined symbols: ___chkstk_darwin`). That makes +> the whole build fail and silently leaves a stale `.a`, so device builds never +> pick up Rust changes. `cargo rustc --crate-type staticlib` builds only the +> `.a` (no link step) — the symbol resolves when Xcode links the extension. + +To verify a freshly built lib contains your change: + +```sh +strings target/aarch64-apple-ios/release-ios/libmhrv_rs.a | grep "" +``` + +## Versioning + +The app version comes from `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` in +`project.yml` (Info.plist references `$(MARKETING_VERSION)` / `$(CURRENT_PROJECT_VERSION)`). +Keep `MARKETING_VERSION` in sync with the crate version in `Cargo.toml`. After +changing it, run `xcodegen generate`. + +## Building a separate copy (own bundle ID) + +The committed bundle ID `com.therealaleph.mhrv` is reserved for the main release. +A bundle ID is globally unique across all of Apple, so if you want to ship your +own build (e.g. a personal TestFlight build that installs **alongside** the main +app without claiming its ID), append a unique suffix of your choice — shown below +as `` (e.g. your initials) — to the IDs **locally**. Do **not** commit this. + +1. `ios/project.yml` — app target: + `PRODUCT_BUNDLE_IDENTIFIER: com.therealaleph.mhrv` → `com.therealaleph.mhrv.` +2. `ios/project.yml` — `MhrvTunnel` target (the extension ID **must** stay a child + of the app ID): + `com.therealaleph.mhrv.tunnel` → `com.therealaleph.mhrv..tunnel` +3. `ios/App/ContentView.swift` — `VpnManager.tunnelId` must equal the extension ID: + `"com.therealaleph.mhrv.tunnel"` → `"com.therealaleph.mhrv..tunnel"` +4. (Optional, for a fully independent data container) change the App Group in both + `*.entitlements` and the `groupId` strings in `ContentView.swift` / + `PacketTunnelProvider.swift` to `group.com.therealaleph.mhrv.`. +5. `cd ios && xcodegen generate`, then create the App Store Connect record for the + new bundle ID. + +Keep these edits out of any PR to the main repo. + +## Publishing to TestFlight + +1. **Bump the version.** Edit `MARKETING_VERSION` (and bump `CURRENT_PROJECT_VERSION`, + which must be unique per upload) in `project.yml`, then `xcodegen generate`. + +2. **App Store Connect setup (one-time).** + - Create the app record at https://appstoreconnect.apple.com with bundle id + `com.therealaleph.mhrv`. + - In the Apple Developer portal, ensure the App ID and the extension App ID + (`com.therealaleph.mhrv.tunnel`) have the **Network Extensions** and + **App Groups** (`group.com.therealaleph.mhrv`) capabilities, and that the + App Group is enabled for both. + - Network Extension on the App Store requires the **Packet Tunnel** capability; + make sure the distribution provisioning profiles include it. + +3. **Signing.** In Xcode → target → Signing & Capabilities, select your Apple + Developer team for both **MhrvVPN** and **MhrvTunnel**. Automatic signing is + fine; otherwise use App Store distribution profiles for both. + +4. **Archive.** Select **Any iOS Device (arm64)** as the run destination + (not a simulator), then **Product → Archive**. The pre-build script builds the + device `aarch64-apple-ios` static lib. + +5. **Upload.** In the Organizer window that opens: **Distribute App → App Store + Connect → Upload**. (Alternatively export the `.ipa` and upload with + Transporter.) Let Xcode manage signing or pick the distribution profiles. + +6. **TestFlight.** After upload, the build appears in App Store Connect → + TestFlight after processing (a few minutes). Complete the **export compliance** + prompt (this app uses standard TLS/HTTPS encryption). Add internal testers + (immediate) or external testers (requires a short Beta App Review). + +### Troubleshooting + +- **Archive missing the Rust lib / link errors:** confirm the pre-build script ran + and produced `ios/build//libmhrv_rs.a`. Re-run `xcodegen generate` if the + script is missing from the target. +- **"stale" behaviour (code changes not taking effect):** you're likely linking an + old `.a` because `cargo build` failed. Use the `cargo rustc --crate-type staticlib` + command above and check the pre-build log for errors. diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 00000000..f9885f4b --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,105 @@ +name: MhrvVPN +options: + bundleIdPrefix: com.therealaleph.mhrv + deploymentTarget: + iOS: "16.0" + xcodeVersion: "15" + createIntermediateGroups: true + +settings: + base: + IPHONEOS_DEPLOYMENT_TARGET: "16.0" + SWIFT_VERSION: "5.9" + ENABLE_BITCODE: NO + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + # Keep in sync with the crate version in Cargo.toml. The toolbar reads + # CFBundleShortVersionString, which Info.plist maps to $(MARKETING_VERSION). + MARKETING_VERSION: "1.9.34" + CURRENT_PROJECT_VERSION: "1" + +packages: {} + +# Signing team comes from a local, gitignored xcconfig so the team ID never +# lands in version control. Copy Local.xcconfig.example -> Local.xcconfig. +configFiles: + Debug: Local.xcconfig + Release: Local.xcconfig + +targets: + + # ── Container app ──────────────────────────────────────────────────────────── + MhrvVPN: + type: application + platform: iOS + sources: + - path: App + excludes: + - "**/.DS_Store" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.therealaleph.mhrv + INFOPLIST_FILE: App/Info.plist + CODE_SIGN_ENTITLEMENTS: App/MhrvVPN.entitlements + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1" + dependencies: + - target: MhrvTunnel + embed: true + + # ── Network Extension (Packet Tunnel Provider) ──────────────────────────────── + MhrvTunnel: + type: app-extension + platform: iOS + sources: + - path: NetworkExtension + excludes: + - "*.h" + - "**/.DS_Store" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.therealaleph.mhrv.tunnel + INFOPLIST_FILE: NetworkExtension/Info.plist + CODE_SIGN_ENTITLEMENTS: NetworkExtension/MhrvTunnel.entitlements + TARGETED_DEVICE_FAMILY: "1" + # Link the Rust static lib. Run scripts/build-ios.sh first. + LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/build/$(CONFIGURATION)" + OTHER_LDFLAGS: "-lmhrv_rs" + # Bridging header so Swift can call our C FFI. + SWIFT_OBJC_BRIDGING_HEADER: NetworkExtension/BridgingHeader.h + preBuildScripts: + - name: Build Rust static lib + script: | + set -e + export PATH="$HOME/.cargo/bin:$PATH" + cd "$SRCROOT/.." + + PROFILE=release-ios + CARGO_FLAGS="--profile release-ios" + if [ "$CONFIGURATION" = "Debug" ]; then + PROFILE=debug + CARGO_FLAGS="" + fi + + # Pick the right Rust target from what Xcode is building for. + if [[ "$PLATFORM_NAME" == *simulator* ]]; then + RUST_TARGET="aarch64-apple-ios-sim" + else + RUST_TARGET="aarch64-apple-ios" + fi + + export DEVELOPER_DIR="$(xcode-select -p)" + export SDKROOT="$(xcrun --sdk iphoneos --show-sdk-path)" + export IPHONEOS_DEPLOYMENT_TARGET="16.0" + + rustup target add "$RUST_TARGET" 2>/dev/null || true + # Build ONLY the staticlib. `cargo build --lib` also links the cdylib, + # whose link fails on iOS (undefined ___chkstk_darwin from C deps under + # -nodefaultlibs), aborting the whole build and leaving a stale .a. + # `cargo rustc --crate-type staticlib` skips the cdylib entirely. + cargo rustc $CARGO_FLAGS --target "$RUST_TARGET" --lib --crate-type staticlib + + mkdir -p "$PROJECT_DIR/build/$CONFIGURATION" + cp "target/${RUST_TARGET}/${PROFILE}/libmhrv_rs.a" \ + "$PROJECT_DIR/build/$CONFIGURATION/libmhrv_rs.a" + outputFiles: + - "$(PROJECT_DIR)/build/$(CONFIGURATION)/libmhrv_rs.a" diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 00000000..edd4a5e2 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Build libmhrv_rs.a for iOS device + simulator and merge into a fat XCFramework. +# +# Prerequisites: +# - Xcode installed (not just CommandLineTools) +# - rustup targets: aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +# - lipo / xcodebuild in PATH (both come with Xcode) +# +# Usage: +# ./scripts/build-ios.sh # release build +# ./scripts/build-ios.sh --debug # debug build + +set -euo pipefail + +PROFILE="release-ios" +CARGO_FLAGS="--profile release-ios" +if [[ "${1:-}" == "--debug" ]]; then + PROFILE="debug" + CARGO_FLAGS="" +fi + +CRATE_NAME="mhrv_rs" +LIB_NAME="lib${CRATE_NAME}.a" +OUT_DIR="ios/build" +XCFW_DIR="${OUT_DIR}/${CRATE_NAME}.xcframework" + +echo "==> Installing required Rust targets..." +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 2>/dev/null || true + +# Build ONLY the staticlib. `cargo build --lib` also links the cdylib, whose +# link fails on iOS (undefined ___chkstk_darwin from C deps under -nodefaultlibs). +# `cargo rustc --crate-type staticlib` produces just libmhrv_rs.a. +echo "==> Building for aarch64-apple-ios (device)..." +cargo rustc ${CARGO_FLAGS} --target aarch64-apple-ios --lib --crate-type staticlib + +echo "==> Building for aarch64-apple-ios-sim (Apple Silicon simulator)..." +cargo rustc ${CARGO_FLAGS} --target aarch64-apple-ios-sim --lib --crate-type staticlib + +echo "==> Building for x86_64-apple-ios (Intel simulator)..." +cargo rustc ${CARGO_FLAGS} --target x86_64-apple-ios --lib --crate-type staticlib + +DEVICE_LIB="target/aarch64-apple-ios/${PROFILE}/${LIB_NAME}" +SIM_ARM_LIB="target/aarch64-apple-ios-sim/${PROFILE}/${LIB_NAME}" +SIM_X86_LIB="target/x86_64-apple-ios/${PROFILE}/${LIB_NAME}" + +echo "==> Merging simulator slices into fat lib..." +mkdir -p "${OUT_DIR}/sim" +lipo -create \ + "${SIM_ARM_LIB}" \ + "${SIM_X86_LIB}" \ + -output "${OUT_DIR}/sim/${LIB_NAME}" + +echo "==> Packaging XCFramework..." +rm -rf "${XCFW_DIR}" +xcodebuild -create-xcframework \ + -library "${DEVICE_LIB}" \ + -headers ios/NetworkExtension \ + -library "${OUT_DIR}/sim/${LIB_NAME}" \ + -headers ios/NetworkExtension \ + -output "${XCFW_DIR}" + +echo "" +echo "Done: ${XCFW_DIR}" +echo " Add ${CRATE_NAME}.xcframework to your Xcode project under the" +echo " NetworkExtension target > Frameworks, Libraries, and Embedded Content." diff --git a/src/ios_ffi.rs b/src/ios_ffi.rs new file mode 100644 index 00000000..a82961e0 --- /dev/null +++ b/src/ios_ffi.rs @@ -0,0 +1,427 @@ +//! C FFI entry points for the iOS Network Extension. +//! +//! Swift (NEPacketTunnelProvider) calls: +//! mhrv_set_data_dir — once, before start, with the App Group container path +//! mhrv_start — starts mhrv-rs SOCKS5 proxy + leaf TUN/FakeIP bridge +//! mhrv_stop — gracefully shuts both down +//! mhrv_drain_logs — drains the in-memory log ring (caller must free) +//! mhrv_free_string — frees a string returned by drain_logs +//! mhrv_version — static version string (no free needed) +//! +//! SAFETY: every entry point catches panics so they never unwind across the +//! C boundary. All pointer arguments are null-checked before use. + +#![cfg(target_os = "ios")] + +use std::collections::VecDeque; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use tokio::sync::{oneshot, Mutex as AsyncMutex}; + +use crate::config::{Config, TomlConfig}; +use crate::mitm::{MitmCertManager, CA_CERT_FILE}; +use crate::proxy_server::ProxyServer; + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +struct IosSession { + proxy_shutdown: Option>, + proxy_rt: Option, + leaf_rt_id: u16, + fronter: Option>, +} + +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); +static LEAF_RT_COUNTER: AtomicU16 = AtomicU16::new(1); + +fn session_map() -> &'static Mutex> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +// --------------------------------------------------------------------------- +// Logging — stderr (shows in Xcode console / idevicesyslog) + ring buffer +// --------------------------------------------------------------------------- + +const LOG_RING_CAP: usize = 500; + +fn log_ring() -> &'static Mutex> { + static RING: OnceLock>> = OnceLock::new(); + RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_RING_CAP))) +} + +struct StderrRingWriter; + +impl std::io::Write for StderrRingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if buf.is_empty() { + return Ok(0); + } + let trimmed = if buf.ends_with(b"\n") { &buf[..buf.len() - 1] } else { buf }; + let _ = std::io::stderr().write_all(trimmed); + let _ = std::io::stderr().write_all(b"\n"); + if let Ok(mut g) = log_ring().lock() { + if g.len() >= LOG_RING_CAP { + g.pop_front(); + } + g.push_back(String::from_utf8_lossy(trimmed).into_owned()); + } + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + std::io::stderr().flush() + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for StderrRingWriter { + type Writer = StderrRingWriter; + fn make_writer(&'a self) -> Self::Writer { + StderrRingWriter + } +} + +fn install_logging_once() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_ansi(false) + .with_writer(StderrRingWriter) + .try_init(); + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Log panic message + source location into the ring buffer. catch_unwind + // swallows the payload, so without this hook a leaf panic is invisible. + std::panic::set_hook(Box::new(|info| { + let loc = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "?".into()); + let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "".to_string() + }; + tracing::error!("PANIC at {}: {}", loc, msg); + })); + }); +} + +fn safe R + std::panic::UnwindSafe, R>(default: R, f: F) -> R { + std::panic::catch_unwind(f).unwrap_or(default) +} + +// --------------------------------------------------------------------------- +// Public C API +// --------------------------------------------------------------------------- + +/// Set the data directory for cert storage. Must be called before mhrv_start. +/// Pass the App Group container path so the extension and host app share certs. +#[no_mangle] +pub extern "C" fn mhrv_set_data_dir(path: *const c_char) { + let _ = safe((), AssertUnwindSafe(|| { + install_logging_once(); + if path.is_null() { + return; + } + let s = unsafe { CStr::from_ptr(path) }.to_string_lossy().into_owned(); + if !s.is_empty() { + crate::data_dir::set_data_dir(std::path::PathBuf::from(s)); + } + })); +} + +/// Start the VPN: mhrv-rs SOCKS5 proxy + leaf TUN/FakeIP bridge. +/// +/// config_json: mhrv-rs config (JSON or TOML). listen_host MUST be "127.0.0.1" +/// so the SOCKS5 listener is loopback-only on the device. +/// tun_fd: file descriptor of the utun device, obtained by the Swift side +/// via `packetFlow.value(forKeyPath: "socket.fileDescriptor")`. +/// +/// Returns a nonzero session handle on success, 0 on failure. +/// The handle is passed to mhrv_stop later. +#[no_mangle] +pub extern "C" fn mhrv_start(config_json: *const c_char, tun_fd: i32) -> u64 { + safe(0u64, AssertUnwindSafe(|| { + install_logging_once(); + + if config_json.is_null() || tun_fd < 0 { + tracing::error!("ios: mhrv_start called with null config or invalid fd"); + return 0; + } + + // dup() so leaf owns an independent fd; original stays valid for packetFlow. + let tun_fd = unsafe { libc::dup(tun_fd) }; + if tun_fd < 0 { + tracing::error!("ios: dup(tun_fd) failed: {}", std::io::Error::last_os_error()); + return 0; + } + tracing::info!("ios: tun_fd duped to {}", tun_fd); + + let raw = unsafe { CStr::from_ptr(config_json) }.to_string_lossy().into_owned(); + + // Parse the mhrv-rs config, forcing loopback listen for security. + let mut config: Config = match serde_json::from_str::(&raw) { + Ok(c) => c, + Err(_) => match toml::from_str::(&raw) { + Ok(tc) => Config::from(tc), + Err(e) => { + tracing::error!("ios: invalid config: {}", e); + return 0; + } + }, + }; + + // Force loopback — the extension must not expose SOCKS5 to the LAN. + config.listen_host = "127.0.0.1".into(); + + let socks_port = config.socks5_port.unwrap_or(config.listen_port + 1); + + // Build the tokio runtime for mhrv-rs. 2 workers — the extension has + // a tight memory budget; more threads don't help on a loopback-only proxy. + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("mhrv-ios") + .build() + { + Ok(r) => r, + Err(e) => { + tracing::error!("ios: tokio build failed: {}", e); + return 0; + } + }; + + let base = crate::data_dir::data_dir(); + let mitm = match MitmCertManager::new_in(&base) { + Ok(m) => m, + Err(e) => { + tracing::error!("ios: MITM CA init: {}", e); + return 0; + } + }; + let mitm = Arc::new(AsyncMutex::new(mitm)); + + let server = match ProxyServer::new(&config, mitm) { + Ok(s) => s, + Err(e) => { + tracing::error!("ios: ProxyServer::new: {}", e); + return 0; + } + }; + let fronter = server.fronter(); + let (tx, rx) = oneshot::channel::<()>(); + + rt.spawn(async move { + if let Err(e) = server.run(rx).await { + tracing::error!("ios: proxy server exited: {}", e); + } + }); + + // Give the proxy a moment to bind before leaf starts sending traffic. + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Start leaf on its own thread (leaf::start blocks until shutdown). + let leaf_rt_id = LEAF_RT_COUNTER.fetch_add(1, Ordering::Relaxed); + let leaf_config = build_leaf_config(tun_fd, socks_port); + + // Flush current log ring to a file so they survive if the extension crashes. + let crash_log_path = crate::data_dir::data_dir().join("mhrv_pre_leaf.log"); + if let Ok(mut g) = log_ring().lock() { + let content = g.iter().cloned().collect::>().join("\n"); + let _ = std::fs::write(&crash_log_path, &content); + } + + std::thread::Builder::new() + .name("leaf-tun".to_string()) + .spawn(move || { + tracing::info!("ios: leaf starting rt_id={} tun_fd={} socks={}", leaf_rt_id, tun_fd, socks_port); + + // catch_unwind requires panic=unwind (release-ios profile). + // Without it, a leaf panic would abort the entire extension process. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let opts = leaf::StartOptions { + config: leaf::Config::Str(leaf_config), + runtime_opt: leaf::RuntimeOption::SingleThread, + }; + leaf::start(leaf_rt_id, opts) + })); + match result { + Ok(Ok(())) => tracing::info!("ios: leaf rt_id={} stopped cleanly", leaf_rt_id), + Ok(Err(e)) => tracing::error!("ios: leaf rt_id={} error: {:?}", leaf_rt_id, e), + Err(_) => tracing::error!("ios: leaf rt_id={} PANICKED — caught by catch_unwind", leaf_rt_id), + } + // After leaf exits for any reason, close the duped fd. + unsafe { libc::close(tun_fd); } + }) + .ok(); + + let session_id = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + session_map().lock().unwrap().insert(session_id, IosSession { + proxy_shutdown: Some(tx), + proxy_rt: Some(rt), + leaf_rt_id, + fronter, + }); + + tracing::info!("ios: session {} started (leaf rt_id={})", session_id, leaf_rt_id); + session_id + })) +} + +/// Stop a session previously returned by mhrv_start. +/// Idempotent: calling with an unknown handle returns false silently. +#[no_mangle] +pub extern "C" fn mhrv_stop(session_id: u64) -> bool { + safe(false, AssertUnwindSafe(|| { + let mut map = session_map().lock().unwrap(); + let Some(mut sess) = map.remove(&session_id) else { + return false; + }; + + // Signal leaf to stop first — it drains in-flight connections before exit. + let leaf_rt_id = sess.leaf_rt_id; + leaf::shutdown(leaf_rt_id); + tracing::info!("ios: leaf rt_id={} shutdown signalled", leaf_rt_id); + + // Signal mhrv-rs proxy. + if let Some(tx) = sess.proxy_shutdown.take() { + let _ = tx.send(()); + } + + drop(map); // release lock before blocking shutdown + + if let Some(rt) = sess.proxy_rt.take() { + rt.shutdown_timeout(std::time::Duration::from_secs(5)); + } + + tracing::info!("ios: session {} stopped", session_id); + true + })) +} + +/// Drain the in-memory log ring as a single '\n'-joined string. +/// CALLER MUST FREE the returned pointer with mhrv_free_string. +/// Returns a valid (possibly empty) C string — never null. +#[no_mangle] +pub extern "C" fn mhrv_drain_logs() -> *mut c_char { + let out = safe(String::new(), AssertUnwindSafe(|| { + let mut g = log_ring().lock().unwrap_or_else(|e| e.into_inner()); + let lines: Vec = g.drain(..).collect(); + lines.join("\n") + })); + CString::new(out).unwrap_or_default().into_raw() +} + +/// Free a string returned by mhrv_drain_logs. +#[no_mangle] +pub extern "C" fn mhrv_free_string(s: *mut c_char) { + if !s.is_null() { + unsafe { drop(CString::from_raw(s)); } + } +} + +/// Return the crate version string. Pointer is static — do NOT free. +#[no_mangle] +pub extern "C" fn mhrv_version() -> *const c_char { + static V: OnceLock = OnceLock::new(); + V.get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).unwrap()).as_ptr() +} + +/// Export the MITM CA certificate to dest_path so the user can install it. +/// Returns true on success. +#[no_mangle] +pub extern "C" fn mhrv_export_ca(dest_path: *const c_char) -> bool { + safe(false, AssertUnwindSafe(|| { + if dest_path.is_null() { return false; } + let dest = unsafe { CStr::from_ptr(dest_path) }.to_string_lossy().into_owned(); + if dest.is_empty() { return false; } + let base = crate::data_dir::data_dir(); + if MitmCertManager::new_in(&base).is_err() { return false; } + let src = base.join(CA_CERT_FILE); + std::fs::copy(&src, &dest).is_ok() + })) +} + +// --------------------------------------------------------------------------- +// Leaf config builder +// --------------------------------------------------------------------------- + +/// Build the leaf JSON config for full-tunnel mode: +/// - TUN inbound on tun_fd with FakeIP DNS (198.18.0.0/15 range) +/// - SOCKS5 outbound → mhrv-rs proxy on 127.0.0.1:socks_port +/// - Loopback and private ranges bypass SOCKS5 (direct) +/// - Everything else → proxy +fn build_leaf_config(tun_fd: i32, socks_port: u16) -> String { + // leaf's own logger does `tracing_subscriber::registry()...init()`, which + // PANICS if a global default subscriber already exists — and we install one + // in install_logging_once. Level "none" makes leaf's setup_logger return + // early before that .init() call, avoiding the panic. As a bonus, leaf then + // never installs its own subscriber, so leaf's tracing events fall through + // to our global subscriber and show up in the log ring / app panel. + let leaf_level = "none"; + // 198.18.0.0/15 is IANA-reserved for benchmarking — safe as FakeIP range. + // Leaf assigns IPs from this pool for intercepted DNS queries and maps them + // back to hostnames when the TUN connection arrives, giving mhrv-rs the + // original domain name for domain-fronting decisions. + format!( + r#"{{ + "log": {{ "level": "{leaf_level}" }}, + "dns": {{ + "servers": ["1.1.1.1", "8.8.8.8"], + "hosts": {{ "localhost": ["127.0.0.1"] }} + }}, + "inbounds": [{{ + "tag": "tun", + "protocol": "tun", + "settings": {{ + "fd": {tun_fd}, + "mtu": 1500, + "fakeDnsExclude": [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "::1/128", + "fe80::/10" + ] + }} + }}], + "outbounds": [ + {{ + "tag": "proxy", + "protocol": "socks", + "settings": {{ + "address": "127.0.0.1", + "port": {socks_port} + }} + }}, + {{ + "tag": "direct", + "protocol": "direct" + }} + ], + "router": {{ + "rules": [ + {{ + "ip": ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128"], + "target": "direct" + }}, + {{ "target": "proxy" }} + ] + }} +}}"# + ) +} diff --git a/src/lib.rs b/src/lib.rs index 6b53a32b..34b1a5d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,3 +17,6 @@ pub mod update_check; #[cfg(target_os = "android")] pub mod android_jni; + +#[cfg(target_os = "ios")] +pub mod ios_ffi;