mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
feat: daemon support, general improvement, and preparation for Windows release
This commit is contained in:
Generated
+494
-7
@@ -537,6 +537,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -651,6 +657,30 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boringtun"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "751787b019c674b9ac353f4eaa285e6711c21badb421cd8c199bf2c83b727f29"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"base64 0.13.1",
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hmac",
|
||||
"ip_network",
|
||||
"ip_network_table",
|
||||
"libc",
|
||||
"nix 0.25.1",
|
||||
"parking_lot",
|
||||
"rand_core 0.6.4",
|
||||
"ring 0.16.20",
|
||||
"tracing",
|
||||
"untrusted 0.9.0",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.0"
|
||||
@@ -947,6 +977,30 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -969,6 +1023,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1158,6 +1213,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1288,6 +1352,33 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.0.0-rc.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436ace70fc06e06f7f689d2624dc4e2f0ea666efb5aa704215f7249ae6e047a7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"fiat-crypto",
|
||||
"platforms",
|
||||
"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.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1329,6 +1420,12 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
@@ -1500,6 +1597,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2 0.6.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1532,12 +1630,14 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"regex-lite",
|
||||
"reqwest 0.13.1",
|
||||
"resvg",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"single-instance",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
"tao",
|
||||
"tar",
|
||||
@@ -1555,6 +1655,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon",
|
||||
@@ -1737,6 +1838,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1829,6 +1939,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1868,6 +1984,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1880,6 +2002,29 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716"
|
||||
dependencies = [
|
||||
"fontconfig-parser",
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -2221,6 +2366,16 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.1"
|
||||
@@ -2785,8 +2940,8 @@ dependencies = [
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"gif 0.14.1",
|
||||
"image-webp 0.2.4",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.0",
|
||||
@@ -2799,6 +2954,16 @@ dependencies = [
|
||||
"zune-jpeg 0.5.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
@@ -2809,6 +2974,12 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.0"
|
||||
@@ -2867,6 +3038,28 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ip_network"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1"
|
||||
|
||||
[[package]]
|
||||
name = "ip_network_table"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4099b7cfc5c5e2fe8c5edf3f6f7adf7a714c9cc697534f63a5a5da30397cb2c0"
|
||||
dependencies = [
|
||||
"ip_network",
|
||||
"ip_network_table-deps-treebitmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ip_network_table-deps-treebitmap"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -3063,6 +3256,17 @@ dependencies = [
|
||||
"selectors",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -3131,6 +3335,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@@ -3336,6 +3546,15 @@ version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.6.5"
|
||||
@@ -3513,6 +3732,18 @@ dependencies = [
|
||||
"memoffset 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -3637,7 +3868,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -4223,6 +4454,12 @@ dependencies = [
|
||||
"siphasher 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -4252,6 +4489,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "platforms"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f21de1852251c849a53467e0ce8b97cca9d11fd4efa3930145c5d5f02f24447"
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "0.0.23"
|
||||
@@ -4328,6 +4571,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -4563,7 +4817,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"ring 0.17.14",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
@@ -4975,6 +5229,23 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958"
|
||||
dependencies = [
|
||||
"gif 0.13.3",
|
||||
"image-webp 0.1.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"usvg",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
@@ -5004,6 +5275,24 @@ name = "rgb"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"spin",
|
||||
"untrusted 0.7.1",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
@@ -5015,7 +5304,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.16",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -5048,6 +5337,12 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.38.0"
|
||||
@@ -5187,9 +5482,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5198,6 +5493,24 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"log",
|
||||
"smallvec",
|
||||
"ttf-parser",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.22"
|
||||
@@ -5675,6 +5988,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "single-instance"
|
||||
version = "0.3.3"
|
||||
@@ -5706,6 +6028,15 @@ version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -5770,6 +6101,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.1"
|
||||
@@ -5789,6 +6126,15 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -5835,6 +6181,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"siphasher 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -5888,6 +6244,15 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.37.2"
|
||||
@@ -6519,6 +6884,32 @@ dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.17.16",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -6843,6 +7234,15 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
@@ -6931,18 +7331,54 @@ version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-vo"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -6959,6 +7395,12 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -6996,6 +7438,33 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"imagesize",
|
||||
"kurbo",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"rustybuzz",
|
||||
"simplecss",
|
||||
"siphasher 1.0.1",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
@@ -8001,6 +8470,18 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.0-rc.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7fae07da688e17059d5886712c933bb0520f15eff2e09cfa18e30968f4e63a"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -8011,6 +8492,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
|
||||
@@ -30,6 +30,7 @@ path = "src/bin/donut_daemon.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
resvg = "0.44"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -48,6 +49,7 @@ env_logger = "0.11"
|
||||
directories = "6"
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "socks"] }
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
tokio-util = "0.7"
|
||||
sysinfo = "0.37"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
@@ -96,6 +98,9 @@ tempfile = "3"
|
||||
maxminddb = "0.27"
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.6"
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.21"
|
||||
muda = "0.17"
|
||||
@@ -104,6 +109,7 @@ single-instance = "0.3"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
crossbeam-channel = "0.5"
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
@@ -151,6 +157,10 @@ path = "tests/donut_proxy_integration.rs"
|
||||
name = "sync_e2e"
|
||||
path = "tests/sync_e2e.rs"
|
||||
|
||||
[[test]]
|
||||
name = "vpn_integration"
|
||||
path = "tests/vpn_integration.rs"
|
||||
|
||||
[profile.dev]
|
||||
codegen-units = 256
|
||||
incremental = true
|
||||
|
||||
@@ -5,6 +5,9 @@ fn main() {
|
||||
// This allows running cargo test without building the frontend first
|
||||
ensure_dist_folder_exists();
|
||||
|
||||
// Generate tray icon PNGs from SVG (macOS template icon format)
|
||||
generate_tray_icons();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
@@ -113,3 +116,56 @@ fn ensure_dist_folder_exists() {
|
||||
|
||||
println!("cargo:rerun-if-changed=../dist");
|
||||
}
|
||||
|
||||
fn generate_tray_icons() {
|
||||
use resvg::tiny_skia::{Pixmap, Transform};
|
||||
use resvg::usvg::{Options, Tree};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let icons_dir = PathBuf::from(&manifest_dir).join("icons");
|
||||
let svg_path = icons_dir.join("tray-icon.svg");
|
||||
|
||||
println!("cargo:rerun-if-changed=icons/tray-icon.svg");
|
||||
|
||||
if !svg_path.exists() {
|
||||
println!("cargo:warning=tray-icon.svg not found, skipping tray icon generation");
|
||||
return;
|
||||
}
|
||||
|
||||
let svg_data = fs::read(&svg_path).expect("Failed to read tray-icon.svg");
|
||||
let tree = Tree::from_data(&svg_data, &Options::default()).expect("Failed to parse SVG");
|
||||
|
||||
// Generate template icons at different sizes for macOS menu bar
|
||||
// 22x22 is standard, 44x44 is retina (@2x)
|
||||
let sizes = [(22, "tray-icon-22.png"), (44, "tray-icon-44.png")];
|
||||
|
||||
for (size, filename) in sizes {
|
||||
let mut pixmap = Pixmap::new(size, size).expect("Failed to create pixmap");
|
||||
|
||||
let svg_size = tree.size();
|
||||
let scale = size as f32 / svg_size.width().max(svg_size.height());
|
||||
let transform = Transform::from_scale(scale, scale);
|
||||
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
|
||||
// Convert to template icon format: black silhouette with alpha channel
|
||||
// macOS will automatically handle light/dark mode by inverting the icon
|
||||
// For template icons: RGB should be 0,0,0 (black) and alpha controls visibility
|
||||
let data = pixmap.data_mut();
|
||||
for pixel in data.chunks_exact_mut(4) {
|
||||
// Keep the original alpha (shows where icon content is)
|
||||
// but make the color black for template icon format
|
||||
pixel[0] = 0; // R
|
||||
pixel[1] = 0; // G
|
||||
pixel[2] = 0; // B
|
||||
// pixel[3] (alpha) stays as-is
|
||||
}
|
||||
|
||||
let output_path = icons_dir.join(filename);
|
||||
pixmap
|
||||
.save_png(&output_path)
|
||||
.expect("Failed to save tray icon PNG");
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 745 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -9,10 +9,10 @@ use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use muda::MenuEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use single_instance::SingleInstance;
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -69,52 +69,6 @@ fn write_state(state: &DaemonState) -> std::io::Result<()> {
|
||||
fs::write(path, content)
|
||||
}
|
||||
|
||||
fn detach_from_parent() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::setsid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_detached() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
match unsafe { libc::fork() } {
|
||||
-1 => {
|
||||
eprintln!("Fork failed");
|
||||
process::exit(1);
|
||||
}
|
||||
0 => {
|
||||
detach_from_parent();
|
||||
}
|
||||
_ => {
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
let current_exe = env::current_exe().expect("Failed to get current exe path");
|
||||
|
||||
let _ = Command::new(current_exe)
|
||||
.arg("--daemon-internal")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
||||
.spawn();
|
||||
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_high_priority() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -174,13 +128,6 @@ fn run_daemon() {
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let instance =
|
||||
SingleInstance::new("donut-browser-daemon").expect("Failed to create single instance lock");
|
||||
if !instance.is_single() {
|
||||
eprintln!("Daemon is already running");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("[daemon] Starting with PID {}", process::id());
|
||||
|
||||
// Create tokio runtime for async operations
|
||||
@@ -231,7 +178,8 @@ fn run_daemon() {
|
||||
|
||||
// Run the event loop
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Poll;
|
||||
// Use WaitUntil to check for menu events periodically while staying low on CPU
|
||||
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
|
||||
|
||||
match event {
|
||||
Event::NewEvents(StartCause::Init) => {
|
||||
@@ -290,7 +238,8 @@ fn run_daemon() {
|
||||
}
|
||||
}
|
||||
|
||||
if SHOULD_QUIT.load(Ordering::SeqCst) {
|
||||
// Use swap to only run cleanup once
|
||||
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
|
||||
// Cleanup
|
||||
let mut state = read_state();
|
||||
state.daemon_pid = None;
|
||||
@@ -405,8 +354,10 @@ fn main() {
|
||||
|
||||
match args[1].as_str() {
|
||||
"start" => {
|
||||
// "start" is now an alias for "run"
|
||||
// On macOS, the daemon should be started via launchctl (see daemon_spawn.rs)
|
||||
// This command is kept for backward compatibility
|
||||
eprintln!("Starting daemon...");
|
||||
spawn_detached();
|
||||
run_daemon();
|
||||
}
|
||||
"stop" => {
|
||||
@@ -418,9 +369,6 @@ fn main() {
|
||||
"run" => {
|
||||
run_daemon();
|
||||
}
|
||||
"--daemon-internal" => {
|
||||
run_daemon();
|
||||
}
|
||||
"autostart" => {
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
|
||||
|
||||
@@ -80,6 +80,12 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
|
||||
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
|
||||
|
||||
// Get log directory (use data directory instead of /tmp)
|
||||
let log_dir = get_data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("logs");
|
||||
fs::create_dir_all(&log_dir)?;
|
||||
|
||||
let plist_content = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -89,21 +95,29 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
<string>com.donutbrowser.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{}</string>
|
||||
<string>start</string>
|
||||
<string>{daemon_path}</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/donut-daemon.out.log</string>
|
||||
<string>{log_dir}/daemon.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/donut-daemon.err.log</string>
|
||||
<string>{log_dir}/daemon.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
daemon_path.display()
|
||||
daemon_path = daemon_path.display(),
|
||||
log_dir = log_dir.display()
|
||||
);
|
||||
|
||||
fs::write(&plist_path, plist_content)?;
|
||||
@@ -112,13 +126,19 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_plist_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn disable_autostart() -> io::Result<()> {
|
||||
let plist_path = dirs::home_dir()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
|
||||
.join("Library/LaunchAgents/com.donutbrowser.daemon.plist");
|
||||
let plist_path = get_plist_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
|
||||
|
||||
if plist_path.exists() {
|
||||
// First unload the launch agent if it's loaded
|
||||
let _ = unload_launch_agent();
|
||||
fs::remove_file(&plist_path)?;
|
||||
log::info!("Removed launch agent at {:?}", plist_path);
|
||||
}
|
||||
@@ -128,12 +148,71 @@ pub fn disable_autostart() -> io::Result<()> {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn is_autostart_enabled() -> bool {
|
||||
dirs::home_dir()
|
||||
.map(|h| {
|
||||
h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist")
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
get_plist_path().is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn load_launch_agent() -> io::Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let plist_path = get_plist_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
|
||||
|
||||
if !plist_path.exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"Launch agent plist does not exist",
|
||||
));
|
||||
}
|
||||
|
||||
// Use launchctl load to start the daemon via launchd
|
||||
// The -w flag writes the "disabled" key to the override plist
|
||||
let output = Command::new("launchctl")
|
||||
.args(["load", "-w"])
|
||||
.arg(&plist_path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// "already loaded" is not an error condition for us
|
||||
if !stderr.contains("already loaded") {
|
||||
return Err(io::Error::other(format!(
|
||||
"launchctl load failed: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Loaded launch agent via launchctl");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn unload_launch_agent() -> io::Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let plist_path = get_plist_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
|
||||
|
||||
if !plist_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new("launchctl")
|
||||
.args(["unload"])
|
||||
.arg(&plist_path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Not being loaded is not an error
|
||||
if !stderr.contains("Could not find specified service") {
|
||||
log::warn!("launchctl unload warning: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Unloaded launch agent via launchctl");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -6,7 +6,9 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn load_icon() -> Icon {
|
||||
let icon_bytes = include_bytes!("../../icons/32x32.png");
|
||||
// Use the generated template icon (44x44 for retina, macOS standard menu bar size)
|
||||
// This is the donut logo converted to template format (black with alpha)
|
||||
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
|
||||
|
||||
let image = image::load_from_memory(icon_bytes)
|
||||
.expect("Failed to load icon")
|
||||
@@ -89,12 +91,16 @@ impl TrayMenu {
|
||||
}
|
||||
|
||||
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
|
||||
TrayIconBuilder::new()
|
||||
let builder = TrayIconBuilder::new()
|
||||
.with_icon(icon)
|
||||
.with_tooltip("Donut Browser")
|
||||
.with_menu(Box::new(menu.clone()))
|
||||
.build()
|
||||
.expect("Failed to create tray icon")
|
||||
.with_menu(Box::new(menu.clone()));
|
||||
|
||||
// On macOS, template icons are automatically colored by the system for light/dark mode
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.with_icon_as_template(true);
|
||||
|
||||
builder.build().expect("Failed to create tray icon")
|
||||
}
|
||||
|
||||
pub fn open_gui() {
|
||||
|
||||
+161
-53
@@ -60,6 +60,38 @@ fn is_daemon_running() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
let path_str = current_exe.to_string_lossy();
|
||||
path_str.contains("target/debug") || path_str.contains("target/release")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_daemon_path() -> Option<PathBuf> {
|
||||
// First try to find the daemon binary next to the current executable
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = current_exe.parent() {
|
||||
let daemon_path = exe_dir.join("donut-daemon");
|
||||
if daemon_path.exists() {
|
||||
return Some(daemon_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try common installation paths
|
||||
let paths = [
|
||||
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
|
||||
.unwrap_or_default(),
|
||||
];
|
||||
paths.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn get_daemon_path() -> Option<PathBuf> {
|
||||
// First, try to find it next to the current executable
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
@@ -68,25 +100,13 @@ fn get_daemon_path() -> Option<PathBuf> {
|
||||
// Check for daemon binary in same directory
|
||||
#[cfg(target_os = "windows")]
|
||||
let daemon_name = "donut-daemon.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
let daemon_name = "donut-daemon";
|
||||
|
||||
let daemon_path = exe_dir.join(daemon_name);
|
||||
if daemon_path.exists() {
|
||||
return Some(daemon_path);
|
||||
}
|
||||
|
||||
// On macOS, check inside the app bundle
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// If we're in Contents/MacOS, daemon should be there too
|
||||
if exe_dir.ends_with("Contents/MacOS") {
|
||||
let daemon_path = exe_dir.join(daemon_name);
|
||||
if daemon_path.exists() {
|
||||
return Some(daemon_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find it in PATH
|
||||
@@ -101,7 +121,7 @@ fn get_daemon_path() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
|
||||
if output.status.success() {
|
||||
@@ -118,60 +138,39 @@ fn get_daemon_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
pub fn spawn_daemon() -> Result<(), String> {
|
||||
// Log the daemon state for debugging
|
||||
let state = read_state();
|
||||
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
|
||||
|
||||
// Check if already running
|
||||
if is_daemon_running() {
|
||||
log::info!("Daemon is already running");
|
||||
log::info!("Daemon is already running (verified by PID check)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Daemon is not running, attempting to start...");
|
||||
|
||||
// Log current exe location for debugging
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
log::info!("Current exe: {:?}", current_exe);
|
||||
|
||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
||||
format!(
|
||||
"Could not find daemon binary. Current exe: {:?}",
|
||||
current_exe
|
||||
)
|
||||
})?;
|
||||
|
||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
||||
|
||||
// Use "run" instead of "start" - we handle detachment here
|
||||
#[cfg(unix)]
|
||||
// On macOS, use launchctl to start the daemon via launchd
|
||||
// This ensures the daemon runs in the user's Aqua session with WindowServer access
|
||||
// and survives app termination since it's managed by launchd, not as a child process
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
spawn_daemon_macos()?;
|
||||
}
|
||||
|
||||
// Create a new process group so daemon survives parent exit
|
||||
// Note: We don't call setsid() because on macOS that disconnects from the WindowServer
|
||||
// which prevents the tray icon from appearing. Instead, we just set a new process group.
|
||||
let mut cmd = Command::new(&daemon_path);
|
||||
cmd
|
||||
.arg("run")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0); // Create new process group without new session
|
||||
|
||||
cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
||||
// On Linux, use direct spawn
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
spawn_daemon_unix()?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
Command::new(&daemon_path)
|
||||
.arg("run")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
||||
spawn_daemon_windows()?;
|
||||
}
|
||||
|
||||
// Wait for daemon to start (max 3 seconds)
|
||||
@@ -196,6 +195,115 @@ pub fn spawn_daemon() -> Result<(), String> {
|
||||
Err("Daemon did not start within timeout".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn spawn_daemon_macos() -> Result<(), String> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
// In dev mode, use direct spawn instead of launchctl
|
||||
// This avoids issues with plist paths pointing to wrong binaries
|
||||
if is_dev_mode() {
|
||||
log::info!("Dev mode detected, using direct spawn instead of launchctl");
|
||||
|
||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
||||
format!(
|
||||
"Could not find daemon binary. Current exe: {:?}",
|
||||
std::env::current_exe().ok()
|
||||
)
|
||||
})?;
|
||||
|
||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
||||
|
||||
// Create a new process group so daemon survives parent exit
|
||||
let mut cmd = Command::new(&daemon_path);
|
||||
cmd
|
||||
.arg("run")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0);
|
||||
|
||||
cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Production mode: use launchctl for proper daemon management
|
||||
// First, ensure the LaunchAgent plist is installed
|
||||
let autostart_enabled = autostart::is_autostart_enabled();
|
||||
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
|
||||
|
||||
if !autostart_enabled {
|
||||
log::info!("Installing LaunchAgent plist for daemon management");
|
||||
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
|
||||
log::info!("LaunchAgent plist installed successfully");
|
||||
}
|
||||
|
||||
// Load the launch agent via launchctl
|
||||
log::info!("Loading daemon via launchctl...");
|
||||
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
|
||||
log::info!("launchctl load completed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_daemon_unix() -> Result<(), String> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
||||
format!(
|
||||
"Could not find daemon binary. Current exe: {:?}",
|
||||
std::env::current_exe().ok()
|
||||
)
|
||||
})?;
|
||||
|
||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
||||
|
||||
// Create a new process group so daemon survives parent exit
|
||||
let mut cmd = Command::new(&daemon_path);
|
||||
cmd
|
||||
.arg("run")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0);
|
||||
|
||||
cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_daemon_windows() -> Result<(), String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
||||
format!(
|
||||
"Could not find daemon binary. Current exe: {:?}",
|
||||
std::env::current_exe().ok()
|
||||
)
|
||||
})?;
|
||||
|
||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
||||
|
||||
Command::new(&daemon_path)
|
||||
.arg("run")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_daemon_running() -> Result<(), String> {
|
||||
if !is_daemon_running() {
|
||||
spawn_daemon()?;
|
||||
|
||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::{create_browser, BrowserType};
|
||||
@@ -13,6 +14,8 @@ use crate::events;
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::Arc::new(Mutex::new(std::collections::HashSet::new()));
|
||||
static ref DOWNLOAD_CANCELLATION_TOKENS: std::sync::Arc<Mutex<std::collections::HashMap<String, CancellationToken>>> =
|
||||
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -438,6 +441,7 @@ impl Downloader {
|
||||
version: &str,
|
||||
download_info: &DownloadInfo,
|
||||
dest_path: &Path,
|
||||
cancel_token: Option<&CancellationToken>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = dest_path.join(&download_info.filename);
|
||||
|
||||
@@ -573,6 +577,13 @@ impl Downloader {
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if let Some(token) = cancel_token {
|
||||
if token.is_cancelled() {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
return Err("Download cancelled".into());
|
||||
}
|
||||
}
|
||||
let chunk = chunk?;
|
||||
io::copy(&mut chunk.as_ref(), &mut file)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
@@ -635,21 +646,27 @@ impl Downloader {
|
||||
browser_str: String,
|
||||
version: String,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if Wayfern terms have been accepted before allowing any browser downloads
|
||||
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
|
||||
// Only check Wayfern terms if Wayfern is already downloaded
|
||||
let terms_manager = crate::wayfern_terms::WayfernTermsManager::instance();
|
||||
if terms_manager.is_wayfern_downloaded() && !terms_manager.is_terms_accepted() {
|
||||
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
||||
}
|
||||
|
||||
// Check if this browser-version pair is already being downloaded
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
{
|
||||
let cancel_token = {
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
if downloading.contains(&download_key) {
|
||||
return Err(format!("Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete.").into());
|
||||
}
|
||||
// Mark this browser-version pair as being downloaded
|
||||
downloading.insert(download_key.clone());
|
||||
}
|
||||
|
||||
let token = CancellationToken::new();
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.insert(download_key.clone(), token.clone());
|
||||
token
|
||||
};
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
@@ -681,6 +698,9 @@ impl Downloader {
|
||||
// Remove from downloading set since it's already downloaded
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Ok(version);
|
||||
} else {
|
||||
// Registry says it's downloaded but files don't exist - clean up registry
|
||||
@@ -702,6 +722,9 @@ impl Downloader {
|
||||
// Remove from downloading set on error
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Err(
|
||||
format!(
|
||||
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
||||
@@ -741,6 +764,7 @@ impl Downloader {
|
||||
&version,
|
||||
&download_info,
|
||||
&browser_dir,
|
||||
Some(&cancel_token),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -752,6 +776,25 @@ impl Downloader {
|
||||
let _ = self.registry.save();
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
|
||||
// Emit cancelled stage if the download was cancelled by user
|
||||
if cancel_token.is_cancelled() {
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "cancelled".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
}
|
||||
|
||||
return Err(format!("Failed to download browser: {e}").into());
|
||||
}
|
||||
};
|
||||
@@ -782,6 +825,10 @@ impl Downloader {
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
return Err(format!("Failed to extract browser: {e}").into());
|
||||
}
|
||||
}
|
||||
@@ -869,6 +916,10 @@ impl Downloader {
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
return Err(error_details.into());
|
||||
}
|
||||
|
||||
@@ -941,11 +992,15 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set
|
||||
// Remove browser-version pair from downloading set and cancel token
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
@@ -964,6 +1019,24 @@ pub async fn download_browser(
|
||||
.map_err(|e| format!("Failed to download browser: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_download(browser_str: String, version: String) -> Result<(), String> {
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
let token = {
|
||||
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.get(&download_key).cloned()
|
||||
};
|
||||
|
||||
if let Some(token) = token {
|
||||
token.cancel();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"No active download found for {browser_str} {version}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1074,6 +1147,7 @@ mod tests {
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1118,6 +1192,7 @@ mod tests {
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1163,6 +1238,7 @@ mod tests {
|
||||
"1465660",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
+248
-4
@@ -47,6 +47,7 @@ pub mod events;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
|
||||
use browser_runner::{
|
||||
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
|
||||
@@ -68,12 +69,12 @@ use downloaded_browsers_registry::{
|
||||
check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions,
|
||||
};
|
||||
|
||||
use downloader::download_browser;
|
||||
use downloader::{cancel_download, download_browser};
|
||||
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings,
|
||||
get_table_sorting_settings, save_app_settings, save_sync_settings, save_table_sorting_settings,
|
||||
should_show_launch_on_login_prompt,
|
||||
get_system_language, get_table_sorting_settings, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
@@ -232,6 +233,41 @@ fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::Prox
|
||||
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_proxies(format: String) -> Result<String, String> {
|
||||
match format.as_str() {
|
||||
"json" => crate::proxy_manager::PROXY_MANAGER.export_proxies_json(),
|
||||
"txt" => Ok(crate::proxy_manager::PROXY_MANAGER.export_proxies_txt()),
|
||||
_ => Err(format!("Unsupported export format: {format}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_proxies_json(
|
||||
app_handle: tauri::AppHandle,
|
||||
content: String,
|
||||
) -> Result<crate::proxy_manager::ProxyImportResult, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.import_proxies_json(&app_handle, &content)
|
||||
.map_err(|e| format!("Failed to import proxies: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn parse_txt_proxies(content: String) -> Vec<crate::proxy_manager::ProxyParseResult> {
|
||||
crate::proxy_manager::ProxyManager::parse_txt_proxies(&content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_proxies_from_parsed(
|
||||
app_handle: tauri::AppHandle,
|
||||
parsed_proxies: Vec<crate::proxy_manager::ParsedProxyLine>,
|
||||
name_prefix: Option<String>,
|
||||
) -> Result<crate::proxy_manager::ProxyImportResult, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.import_proxies_from_parsed(&app_handle, parsed_proxies, name_prefix)
|
||||
.map_err(|e| format!("Failed to import proxies: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
cookie_manager::CookieManager::read_cookies(&profile_id)
|
||||
@@ -250,6 +286,11 @@ fn check_wayfern_terms_accepted() -> bool {
|
||||
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_wayfern_downloaded() -> bool {
|
||||
wayfern_terms::WayfernTermsManager::instance().is_wayfern_downloaded()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn accept_wayfern_terms() -> Result<(), String> {
|
||||
wayfern_terms::WayfernTermsManager::instance()
|
||||
@@ -374,6 +415,172 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
|
||||
.map_err(|e| format!("Failed to download GeoIP database: {e}"))
|
||||
}
|
||||
|
||||
// VPN commands
|
||||
#[tauri::command]
|
||||
async fn import_vpn_config(
|
||||
content: String,
|
||||
filename: String,
|
||||
name: Option<String>,
|
||||
) -> Result<vpn::VpnImportResult, String> {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
match storage.import_config(&content, &filename, name.clone()) {
|
||||
Ok(config) => Ok(vpn::VpnImportResult {
|
||||
success: true,
|
||||
vpn_id: Some(config.id),
|
||||
vpn_type: Some(config.vpn_type),
|
||||
name: config.name,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(vpn::VpnImportResult {
|
||||
success: false,
|
||||
vpn_id: None,
|
||||
vpn_type: None,
|
||||
name: name.unwrap_or_else(|| filename.clone()),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_vpn_configs() -> Result<Vec<vpn::VpnConfig>, String> {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.list_configs()
|
||||
.map_err(|e| format!("Failed to list VPN configs: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_config(vpn_id: String) -> Result<vpn::VpnConfig, String> {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.load_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_vpn_config(vpn_id: String) -> Result<(), String> {
|
||||
// First disconnect if connected
|
||||
{
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
if manager.is_tunnel_active(&vpn_id) {
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
|
||||
let _ = tunnel.disconnect().await;
|
||||
}
|
||||
manager.remove_tunnel(&vpn_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Then delete from storage
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.delete_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to delete VPN config: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
// Load config from storage
|
||||
let config = {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.load_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))?
|
||||
};
|
||||
|
||||
// Create and connect the appropriate tunnel
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
// Check if already connected
|
||||
if manager.is_tunnel_active(&vpn_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut tunnel: Box<dyn vpn::VpnTunnel> = match config.vpn_type {
|
||||
vpn::VpnType::WireGuard => {
|
||||
let wg_config = vpn::parse_wireguard_config(&config.config_data)
|
||||
.map_err(|e| format!("Invalid WireGuard config: {e}"))?;
|
||||
Box::new(vpn::WireGuardTunnel::new(vpn_id.clone(), wg_config))
|
||||
}
|
||||
vpn::VpnType::OpenVPN => {
|
||||
let ovpn_config = vpn::parse_openvpn_config(&config.config_data)
|
||||
.map_err(|e| format!("Invalid OpenVPN config: {e}"))?;
|
||||
Box::new(vpn::OpenVpnTunnel::new(vpn_id.clone(), ovpn_config))
|
||||
}
|
||||
};
|
||||
|
||||
tunnel
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect VPN: {e}"))?;
|
||||
|
||||
manager.register_tunnel(vpn_id.clone(), tunnel);
|
||||
|
||||
// Update last_used timestamp
|
||||
{
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
let _ = storage.update_last_used(&vpn_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn disconnect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
|
||||
tunnel
|
||||
.disconnect()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
|
||||
}
|
||||
|
||||
manager.remove_tunnel(&vpn_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
|
||||
let manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel(&vpn_id) {
|
||||
Ok(tunnel.get_status())
|
||||
} else {
|
||||
// Not connected
|
||||
Ok(vpn::VpnStatus {
|
||||
connected: false,
|
||||
vpn_id,
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
|
||||
let manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
Ok(manager.get_all_statuses())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
@@ -883,6 +1090,7 @@ pub fn run() {
|
||||
get_supported_browsers,
|
||||
is_browser_supported_on_platform,
|
||||
download_browser,
|
||||
cancel_download,
|
||||
delete_profile,
|
||||
check_browser_exists,
|
||||
create_browser_profile_new,
|
||||
@@ -907,6 +1115,7 @@ pub fn run() {
|
||||
decline_launch_on_login,
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
get_system_language,
|
||||
clear_all_version_cache_and_refetch,
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
@@ -931,6 +1140,10 @@ pub fn run() {
|
||||
delete_stored_proxy,
|
||||
check_proxy_validity,
|
||||
get_cached_proxy_check,
|
||||
export_proxies,
|
||||
import_proxies_json,
|
||||
parse_txt_proxies,
|
||||
import_proxies_from_parsed,
|
||||
update_camoufox_config,
|
||||
update_wayfern_config,
|
||||
get_profile_groups,
|
||||
@@ -959,6 +1172,7 @@ pub fn run() {
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies,
|
||||
check_wayfern_terms_accepted,
|
||||
check_wayfern_downloaded,
|
||||
accept_wayfern_terms,
|
||||
get_commercial_trial_status,
|
||||
acknowledge_trial_expiration,
|
||||
@@ -966,7 +1180,16 @@ pub fn run() {
|
||||
start_mcp_server,
|
||||
stop_mcp_server,
|
||||
get_mcp_server_status,
|
||||
get_mcp_config
|
||||
get_mcp_config,
|
||||
// VPN commands
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
get_vpn_config,
|
||||
delete_vpn_config,
|
||||
connect_vpn,
|
||||
disconnect_vpn,
|
||||
get_vpn_status,
|
||||
list_active_vpn_connections
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@@ -987,6 +1210,18 @@ mod tests {
|
||||
}
|
||||
|
||||
fn check_unused_commands(verbose: bool) {
|
||||
// Commands that are intentionally not used in the frontend
|
||||
// but are used via MCP server or other programmatic APIs
|
||||
let mcp_only_commands = [
|
||||
"list_vpn_configs",
|
||||
"get_vpn_config",
|
||||
"delete_vpn_config",
|
||||
"connect_vpn",
|
||||
"disconnect_vpn",
|
||||
"get_vpn_status",
|
||||
"list_active_vpn_connections",
|
||||
];
|
||||
|
||||
// Extract command names from the generate_handler! macro in this file
|
||||
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
|
||||
let commands = extract_tauri_commands(&lib_rs_content);
|
||||
@@ -999,6 +1234,15 @@ mod tests {
|
||||
let mut used_commands = Vec::new();
|
||||
|
||||
for command in &commands {
|
||||
// Skip commands that are intentionally MCP-only
|
||||
if mcp_only_commands.contains(&command.as_str()) {
|
||||
used_commands.push(command.clone());
|
||||
if verbose {
|
||||
println!("✅ {command} (MCP-only)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut is_used = false;
|
||||
|
||||
for file_content in &frontend_files {
|
||||
|
||||
+533
-2
@@ -553,6 +553,132 @@ impl McpServer {
|
||||
"required": ["proxy_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "export_proxies".to_string(),
|
||||
description: "Export all proxy configurations".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "txt"],
|
||||
"description": "Export format (json for structured data, txt for URL format)"
|
||||
}
|
||||
},
|
||||
"required": ["format"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "import_proxies".to_string(),
|
||||
description: "Import proxy configurations from JSON or TXT content".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The proxy configuration content to import"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "txt"],
|
||||
"description": "Import format (json or txt)"
|
||||
},
|
||||
"name_prefix": {
|
||||
"type": "string",
|
||||
"description": "Optional prefix for imported proxy names (default: 'Imported')"
|
||||
}
|
||||
},
|
||||
"required": ["content", "format"]
|
||||
}),
|
||||
},
|
||||
// VPN management tools
|
||||
McpTool {
|
||||
name: "import_vpn".to_string(),
|
||||
description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Raw VPN config file content"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Original filename (.conf or .ovpn) for type detection"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional display name for the VPN config"
|
||||
}
|
||||
},
|
||||
"required": ["content", "filename"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_vpn_configs".to_string(),
|
||||
description: "List all stored VPN configurations".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_vpn".to_string(),
|
||||
description: "Delete a VPN configuration".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vpn_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the VPN config to delete"
|
||||
}
|
||||
},
|
||||
"required": ["vpn_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "connect_vpn".to_string(),
|
||||
description: "Connect to a VPN configuration".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vpn_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the VPN config to connect"
|
||||
}
|
||||
},
|
||||
"required": ["vpn_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "disconnect_vpn".to_string(),
|
||||
description: "Disconnect from a VPN".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vpn_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the VPN to disconnect"
|
||||
}
|
||||
},
|
||||
"required": ["vpn_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_vpn_status".to_string(),
|
||||
description: "Get the connection status of a VPN".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vpn_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the VPN to check"
|
||||
}
|
||||
},
|
||||
"required": ["vpn_id"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -641,6 +767,16 @@ impl McpServer {
|
||||
"create_proxy" => self.handle_create_proxy(&arguments).await,
|
||||
"update_proxy" => self.handle_update_proxy(&arguments).await,
|
||||
"delete_proxy" => self.handle_delete_proxy(&arguments).await,
|
||||
// Proxy import/export
|
||||
"export_proxies" => self.handle_export_proxies(&arguments).await,
|
||||
"import_proxies" => self.handle_import_proxies(&arguments).await,
|
||||
// VPN management
|
||||
"import_vpn" => self.handle_import_vpn(&arguments).await,
|
||||
"list_vpn_configs" => self.handle_list_vpn_configs().await,
|
||||
"delete_vpn" => self.handle_delete_vpn(&arguments).await,
|
||||
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -1361,6 +1497,391 @@ impl McpServer {
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_export_proxies(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let format = arguments
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing format".to_string(),
|
||||
})?;
|
||||
|
||||
let content = match format {
|
||||
"json" => PROXY_MANAGER.export_proxies_json().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to export proxies: {e}"),
|
||||
})?,
|
||||
"txt" => PROXY_MANAGER.export_proxies_txt(),
|
||||
_ => {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": content
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_import_proxies(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let content = arguments
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing content".to_string(),
|
||||
})?;
|
||||
|
||||
let format = arguments
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing format".to_string(),
|
||||
})?;
|
||||
|
||||
let name_prefix = arguments
|
||||
.get("name_prefix")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let inner = self.inner.lock().await;
|
||||
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
let result = match format {
|
||||
"json" => PROXY_MANAGER
|
||||
.import_proxies_json(app_handle, content)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to import proxies: {e}"),
|
||||
})?,
|
||||
"txt" => {
|
||||
use crate::proxy_manager::{ProxyManager, ProxyParseResult};
|
||||
|
||||
let parse_results = ProxyManager::parse_txt_proxies(content);
|
||||
let parsed: Vec<_> = parse_results
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
if let ProxyParseResult::Parsed(p) = r {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if parsed.is_empty() {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "No valid proxies found in content".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
PROXY_MANAGER
|
||||
.import_proxies_from_parsed(app_handle, parsed, name_prefix)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to import proxies: {e}"),
|
||||
})?
|
||||
}
|
||||
_ => {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid format '{}', must be 'json' or 'txt'", format),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Import complete: {} imported, {} skipped, {} errors",
|
||||
result.imported_count,
|
||||
result.skipped_count,
|
||||
result.errors.len()
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// VPN management handlers
|
||||
async fn handle_import_vpn(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let content = arguments
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing content".to_string(),
|
||||
})?;
|
||||
|
||||
let filename = arguments
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing filename".to_string(),
|
||||
})?;
|
||||
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
})?;
|
||||
|
||||
let config = storage
|
||||
.import_config(content, filename, name)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to import VPN config: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"VPN '{}' ({}) imported successfully with ID: {}",
|
||||
config.name,
|
||||
config.vpn_type,
|
||||
config.id
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_vpn_configs(&self) -> Result<serde_json::Value, McpError> {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
})?;
|
||||
|
||||
let configs = storage.list_configs().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list VPN configs: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&configs).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_delete_vpn(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let vpn_id = arguments
|
||||
.get("vpn_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
// First disconnect if connected
|
||||
{
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
if manager.is_tunnel_active(vpn_id) {
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
|
||||
let _ = tunnel.disconnect().await;
|
||||
}
|
||||
manager.remove_tunnel(vpn_id);
|
||||
}
|
||||
}
|
||||
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
})?;
|
||||
|
||||
storage.delete_config(vpn_id).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to delete VPN config: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' deleted successfully", vpn_id)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_connect_vpn(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let vpn_id = arguments
|
||||
.get("vpn_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
// Load config from storage
|
||||
let config = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
})?;
|
||||
|
||||
storage.load_config(vpn_id).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to load VPN config: {e}"),
|
||||
})?
|
||||
};
|
||||
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
// Check if already connected
|
||||
if manager.is_tunnel_active(vpn_id) {
|
||||
return Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' is already connected", config.name)
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
let mut tunnel: Box<dyn crate::vpn::VpnTunnel> = match config.vpn_type {
|
||||
crate::vpn::VpnType::WireGuard => {
|
||||
let wg_config =
|
||||
crate::vpn::parse_wireguard_config(&config.config_data).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Invalid WireGuard config: {e}"),
|
||||
})?;
|
||||
Box::new(crate::vpn::WireGuardTunnel::new(
|
||||
vpn_id.to_string(),
|
||||
wg_config,
|
||||
))
|
||||
}
|
||||
crate::vpn::VpnType::OpenVPN => {
|
||||
let ovpn_config =
|
||||
crate::vpn::parse_openvpn_config(&config.config_data).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Invalid OpenVPN config: {e}"),
|
||||
})?;
|
||||
Box::new(crate::vpn::OpenVpnTunnel::new(
|
||||
vpn_id.to_string(),
|
||||
ovpn_config,
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
tunnel.connect().await.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to connect VPN: {e}"),
|
||||
})?;
|
||||
|
||||
manager.register_tunnel(vpn_id.to_string(), tunnel);
|
||||
|
||||
// Update last_used timestamp
|
||||
{
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
})?;
|
||||
let _ = storage.update_last_used(vpn_id);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' connected successfully", config.name)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_disconnect_vpn(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let vpn_id = arguments
|
||||
.get("vpn_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
|
||||
tunnel.disconnect().await.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to disconnect VPN: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
manager.remove_tunnel(vpn_id);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' disconnected successfully", vpn_id)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_vpn_status(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let vpn_id = arguments
|
||||
.get("vpn_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
let manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
let status = if let Some(tunnel) = manager.get_tunnel(vpn_id) {
|
||||
tunnel.get_status()
|
||||
} else {
|
||||
crate::vpn::VpnStatus {
|
||||
connected: false,
|
||||
vpn_id: vpn_id.to_string(),
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&status).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1376,8 +1897,8 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have all 16 tools
|
||||
assert!(tools.len() >= 16);
|
||||
// Should have at least 24 tools (18 + 6 VPN tools)
|
||||
assert!(tools.len() >= 24);
|
||||
|
||||
// Check tool names
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
@@ -1400,6 +1921,16 @@ mod tests {
|
||||
assert!(tool_names.contains(&"create_proxy"));
|
||||
assert!(tool_names.contains(&"update_proxy"));
|
||||
assert!(tool_names.contains(&"delete_proxy"));
|
||||
// Proxy import/export tools
|
||||
assert!(tool_names.contains(&"export_proxies"));
|
||||
assert!(tool_names.contains(&"import_proxies"));
|
||||
// VPN tools
|
||||
assert!(tool_names.contains(&"import_vpn"));
|
||||
assert!(tool_names.contains(&"list_vpn_configs"));
|
||||
assert!(tool_names.contains(&"delete_vpn"));
|
||||
assert!(tool_names.contains(&"connect_vpn"));
|
||||
assert!(tool_names.contains(&"disconnect_vpn"));
|
||||
assert!(tool_names.contains(&"get_vpn_status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -12,6 +13,60 @@ use crate::browser::ProxySettings;
|
||||
use crate::events;
|
||||
use crate::ip_utils;
|
||||
|
||||
// Export data format for JSON export
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyExportData {
|
||||
pub version: String,
|
||||
pub proxies: Vec<ExportedProxy>,
|
||||
pub exported_at: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportedProxy {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub proxy_type: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyImportResult {
|
||||
pub imported_count: usize,
|
||||
pub skipped_count: usize,
|
||||
pub errors: Vec<String>,
|
||||
pub proxies: Vec<StoredProxy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParsedProxyLine {
|
||||
pub proxy_type: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub original_line: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum ProxyParseResult {
|
||||
#[serde(rename = "parsed")]
|
||||
Parsed(ParsedProxyLine),
|
||||
#[serde(rename = "ambiguous")]
|
||||
Ambiguous {
|
||||
line: String,
|
||||
possible_formats: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "invalid")]
|
||||
Invalid { line: String, reason: String },
|
||||
}
|
||||
|
||||
// Store active proxy information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyInfo {
|
||||
@@ -541,6 +596,331 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let proxies: Vec<ExportedProxy> = stored_proxies
|
||||
.values()
|
||||
.map(|p| ExportedProxy {
|
||||
name: p.name.clone(),
|
||||
proxy_type: p.proxy_settings.proxy_type.clone(),
|
||||
host: p.proxy_settings.host.clone(),
|
||||
port: p.proxy_settings.port,
|
||||
username: p.proxy_settings.username.clone(),
|
||||
password: p.proxy_settings.password.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let export_data = ProxyExportData {
|
||||
version: "1.0".to_string(),
|
||||
proxies,
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
source: "DonutBrowser".to_string(),
|
||||
};
|
||||
|
||||
serde_json::to_string_pretty(&export_data).map_err(|e| format!("Failed to serialize: {e}"))
|
||||
}
|
||||
|
||||
// Export all proxies as TXT (one per line: protocol://user:pass@host:port)
|
||||
pub fn export_proxies_txt(&self) -> String {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies
|
||||
.values()
|
||||
.map(|p| Self::build_proxy_url(&p.proxy_settings))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Parse TXT content with auto-detection of formats
|
||||
pub fn parse_txt_proxies(content: &str) -> Vec<ProxyParseResult> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
|
||||
.map(|line| Self::parse_single_proxy_line(line.trim()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Parse a single proxy line with format auto-detection
|
||||
fn parse_single_proxy_line(line: &str) -> ProxyParseResult {
|
||||
// Format 1: protocol://username:password@host:port (full URL)
|
||||
if let Some(result) = Self::try_parse_url_format(line) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try colon-separated formats
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
|
||||
match parts.len() {
|
||||
// host:port (no auth)
|
||||
2 => {
|
||||
if let Ok(port) = parts[1].parse::<u16>() {
|
||||
return ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: parts[0].to_string(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
original_line: line.to_string(),
|
||||
});
|
||||
}
|
||||
ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "Invalid port number".to_string(),
|
||||
}
|
||||
}
|
||||
// Could be: host:port:user or user:pass@host (with @ in the middle)
|
||||
3 => {
|
||||
// Try username:password@host:port first
|
||||
if let Some(result) = Self::try_parse_user_pass_at_host_port(line) {
|
||||
return result;
|
||||
}
|
||||
ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "Could not determine format with 3 parts".to_string(),
|
||||
}
|
||||
}
|
||||
// 4 parts: could be host:port:user:pass OR user:pass:host:port
|
||||
4 => {
|
||||
// Try to detect which format
|
||||
let port_at_1 = parts[1].parse::<u16>().is_ok();
|
||||
let port_at_3 = parts[3].parse::<u16>().is_ok();
|
||||
|
||||
match (port_at_1, port_at_3) {
|
||||
// host:port:user:pass
|
||||
(true, false) => {
|
||||
let port = parts[1].parse::<u16>().unwrap();
|
||||
ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: parts[0].to_string(),
|
||||
port,
|
||||
username: Some(parts[2].to_string()),
|
||||
password: Some(parts[3].to_string()),
|
||||
original_line: line.to_string(),
|
||||
})
|
||||
}
|
||||
// user:pass:host:port
|
||||
(false, true) => {
|
||||
let port = parts[3].parse::<u16>().unwrap();
|
||||
ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: parts[2].to_string(),
|
||||
port,
|
||||
username: Some(parts[0].to_string()),
|
||||
password: Some(parts[1].to_string()),
|
||||
original_line: line.to_string(),
|
||||
})
|
||||
}
|
||||
// Both could be ports - ambiguous
|
||||
(true, true) => ProxyParseResult::Ambiguous {
|
||||
line: line.to_string(),
|
||||
possible_formats: vec![
|
||||
"host:port:username:password".to_string(),
|
||||
"username:password:host:port".to_string(),
|
||||
],
|
||||
},
|
||||
// Neither is a valid port
|
||||
(false, false) => ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "No valid port number found".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: format!("Unexpected format with {} parts", parts.len()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse URL format: protocol://username:password@host:port
|
||||
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
|
||||
// Check for protocol prefix using strip_prefix
|
||||
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
|
||||
("http", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("https://") {
|
||||
("https", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("socks4://") {
|
||||
("socks4", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("socks5://") {
|
||||
("socks5", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("socks://") {
|
||||
("socks5", rest) // Default socks to socks5
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Check if there's auth (contains @)
|
||||
if let Some(at_pos) = rest.rfind('@') {
|
||||
let auth = &rest[..at_pos];
|
||||
let host_port = &rest[at_pos + 1..];
|
||||
|
||||
// Parse auth (user:pass)
|
||||
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
||||
let user = urlencoding::decode(&auth[..colon_pos]).unwrap_or_default();
|
||||
let pass = urlencoding::decode(&auth[colon_pos + 1..]).unwrap_or_default();
|
||||
(Some(user.to_string()), Some(pass.to_string()))
|
||||
} else {
|
||||
(
|
||||
Some(urlencoding::decode(auth).unwrap_or_default().to_string()),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// Parse host:port
|
||||
if let Some(colon_pos) = host_port.rfind(':') {
|
||||
let host = &host_port[..colon_pos];
|
||||
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
|
||||
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: protocol.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
original_line: line.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No auth, just host:port
|
||||
if let Some(colon_pos) = rest.rfind(':') {
|
||||
let host = &rest[..colon_pos];
|
||||
if let Ok(port) = rest[colon_pos + 1..].parse::<u16>() {
|
||||
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: protocol.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
original_line: line.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ProxyParseResult::Invalid {
|
||||
line: line.to_string(),
|
||||
reason: "Invalid URL format".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// Try to parse: username:password@host:port format (no protocol)
|
||||
fn try_parse_user_pass_at_host_port(line: &str) -> Option<ProxyParseResult> {
|
||||
if let Some(at_pos) = line.rfind('@') {
|
||||
let auth = &line[..at_pos];
|
||||
let host_port = &line[at_pos + 1..];
|
||||
|
||||
// Parse auth
|
||||
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
||||
(
|
||||
Some(auth[..colon_pos].to_string()),
|
||||
Some(auth[colon_pos + 1..].to_string()),
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Parse host:port
|
||||
if let Some(colon_pos) = host_port.rfind(':') {
|
||||
let host = &host_port[..colon_pos];
|
||||
if let Ok(port) = host_port[colon_pos + 1..].parse::<u16>() {
|
||||
return Some(ProxyParseResult::Parsed(ParsedProxyLine {
|
||||
proxy_type: "http".to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
original_line: line.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Import proxies from JSON content
|
||||
pub fn import_proxies_json(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
content: &str,
|
||||
) -> Result<ProxyImportResult, String> {
|
||||
let export_data: ProxyExportData =
|
||||
serde_json::from_str(content).map_err(|e| format!("Invalid JSON format: {e}"))?;
|
||||
|
||||
let mut imported = Vec::new();
|
||||
let mut skipped = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for exported in export_data.proxies {
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: exported.proxy_type,
|
||||
host: exported.host,
|
||||
port: exported.port,
|
||||
username: exported.username,
|
||||
password: exported.password,
|
||||
};
|
||||
|
||||
match self.create_stored_proxy(app_handle, exported.name.clone(), proxy_settings) {
|
||||
Ok(proxy) => imported.push(proxy),
|
||||
Err(e) => {
|
||||
if e.contains("already exists") {
|
||||
skipped += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to import '{}': {}", exported.name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProxyImportResult {
|
||||
imported_count: imported.len(),
|
||||
skipped_count: skipped,
|
||||
errors,
|
||||
proxies: imported,
|
||||
})
|
||||
}
|
||||
|
||||
// Import proxies from already parsed proxy lines
|
||||
pub fn import_proxies_from_parsed(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
parsed_proxies: Vec<ParsedProxyLine>,
|
||||
name_prefix: Option<String>,
|
||||
) -> Result<ProxyImportResult, String> {
|
||||
let mut imported = Vec::new();
|
||||
let mut skipped = 0;
|
||||
let mut errors = Vec::new();
|
||||
let prefix = name_prefix.unwrap_or_else(|| "Imported".to_string());
|
||||
|
||||
for (i, parsed) in parsed_proxies.into_iter().enumerate() {
|
||||
let proxy_name = format!("{} Proxy {}", prefix, i + 1);
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
};
|
||||
|
||||
match self.create_stored_proxy(app_handle, proxy_name.clone(), proxy_settings) {
|
||||
Ok(proxy) => imported.push(proxy),
|
||||
Err(e) => {
|
||||
if e.contains("already exists") {
|
||||
skipped += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to import '{}': {}", proxy_name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProxyImportResult {
|
||||
imported_count: imported.len(),
|
||||
skipped_count: skipped,
|
||||
errors,
|
||||
proxies: imported,
|
||||
})
|
||||
}
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
pub async fn start_proxy(
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct AppSettings {
|
||||
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
|
||||
#[serde(default)]
|
||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
||||
#[serde(default)]
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -84,6 +86,7 @@ impl Default for AppSettings {
|
||||
mcp_port: None,
|
||||
mcp_token: None,
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -809,6 +812,17 @@ pub async fn save_app_settings(
|
||||
let mut persist_settings = settings.clone();
|
||||
persist_settings.api_token = None;
|
||||
persist_settings.mcp_token = None;
|
||||
|
||||
log::info!(
|
||||
"[settings] Saving settings: theme={}, custom_theme_keys={}",
|
||||
persist_settings.theme,
|
||||
persist_settings
|
||||
.custom_theme
|
||||
.as_ref()
|
||||
.map(|t| t.len())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
manager
|
||||
.save_settings(&persist_settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
@@ -899,6 +913,20 @@ pub async fn save_sync_settings(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_language() -> String {
|
||||
sys_locale::get_locale()
|
||||
.map(|locale| {
|
||||
// Extract just the language code (e.g., "en" from "en-US")
|
||||
locale
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or("en")
|
||||
.to_lowercase()
|
||||
})
|
||||
.unwrap_or_else(|| "en".to_string())
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
@@ -985,6 +1013,7 @@ mod tests {
|
||||
mcp_port: None,
|
||||
mcp_token: None,
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
//! VPN configuration types and parsing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
/// VPN-related errors
|
||||
#[derive(Error, Debug)]
|
||||
pub enum VpnError {
|
||||
#[error("Unknown VPN config format")]
|
||||
UnknownFormat,
|
||||
#[error("Invalid WireGuard config: {0}")]
|
||||
InvalidWireGuard(String),
|
||||
#[error("Invalid OpenVPN config: {0}")]
|
||||
InvalidOpenVpn(String),
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Connection error: {0}")]
|
||||
Connection(String),
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("VPN not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Tunnel error: {0}")]
|
||||
Tunnel(String),
|
||||
}
|
||||
|
||||
/// The type of VPN configuration
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VpnType {
|
||||
WireGuard,
|
||||
OpenVPN,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VpnType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VpnType::WireGuard => write!(f, "WireGuard"),
|
||||
VpnType::OpenVPN => write!(f, "OpenVPN"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored VPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vpn_type: VpnType,
|
||||
pub config_data: String, // Raw config content (encrypted at rest)
|
||||
pub created_at: i64,
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// Parsed WireGuard configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WireGuardConfig {
|
||||
pub private_key: String,
|
||||
pub address: String,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub peer_public_key: String,
|
||||
pub peer_endpoint: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
pub preshared_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed OpenVPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenVpnConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String, // "udp" or "tcp"
|
||||
pub dev_type: String, // "tun" or "tap"
|
||||
pub has_inline_ca: bool,
|
||||
pub has_inline_cert: bool,
|
||||
pub has_inline_key: bool,
|
||||
}
|
||||
|
||||
/// Result of importing a VPN configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnImportResult {
|
||||
pub success: bool,
|
||||
pub vpn_id: Option<String>,
|
||||
pub vpn_type: Option<VpnType>,
|
||||
pub name: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// VPN connection status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnStatus {
|
||||
pub connected: bool,
|
||||
pub vpn_id: String,
|
||||
pub connected_at: Option<i64>,
|
||||
pub bytes_sent: Option<u64>,
|
||||
pub bytes_received: Option<u64>,
|
||||
pub last_handshake: Option<i64>,
|
||||
}
|
||||
|
||||
/// Detect the VPN type from file content and filename
|
||||
pub fn detect_vpn_type(content: &str, filename: &str) -> Result<VpnType, VpnError> {
|
||||
let filename_lower = filename.to_lowercase();
|
||||
|
||||
// Check file extension first
|
||||
if filename_lower.ends_with(".conf") {
|
||||
// .conf could be WireGuard - check content
|
||||
if content.contains("[Interface]") && content.contains("[Peer]") {
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
}
|
||||
|
||||
if filename_lower.ends_with(".ovpn") {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
// Check content patterns
|
||||
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
|
||||
{
|
||||
return Ok(VpnType::WireGuard);
|
||||
}
|
||||
|
||||
if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) {
|
||||
return Ok(VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
Err(VpnError::UnknownFormat)
|
||||
}
|
||||
|
||||
/// Parse a WireGuard configuration file
|
||||
pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError> {
|
||||
let mut interface: HashMap<String, String> = HashMap::new();
|
||||
let mut peer: HashMap<String, String> = HashMap::new();
|
||||
let mut current_section: Option<&str> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for section headers
|
||||
if line == "[Interface]" {
|
||||
current_section = Some("interface");
|
||||
continue;
|
||||
}
|
||||
if line == "[Peer]" {
|
||||
current_section = Some("peer");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key-value pairs
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim().to_string();
|
||||
|
||||
match current_section {
|
||||
Some("interface") => {
|
||||
interface.insert(key, value);
|
||||
}
|
||||
Some("peer") => {
|
||||
peer.insert(key, value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
let private_key = interface
|
||||
.get("PrivateKey")
|
||||
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PrivateKey in [Interface]".to_string()))?
|
||||
.clone();
|
||||
|
||||
let address = interface
|
||||
.get("Address")
|
||||
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Address in [Interface]".to_string()))?
|
||||
.clone();
|
||||
|
||||
let peer_public_key = peer
|
||||
.get("PublicKey")
|
||||
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PublicKey in [Peer]".to_string()))?
|
||||
.clone();
|
||||
|
||||
let peer_endpoint = peer
|
||||
.get("Endpoint")
|
||||
.ok_or_else(|| VpnError::InvalidWireGuard("Missing Endpoint in [Peer]".to_string()))?
|
||||
.clone();
|
||||
|
||||
let allowed_ips = peer
|
||||
.get("AllowedIPs")
|
||||
.map(|s| s.split(',').map(|ip| ip.trim().to_string()).collect())
|
||||
.unwrap_or_else(|| vec!["0.0.0.0/0".to_string()]);
|
||||
|
||||
let persistent_keepalive = peer.get("PersistentKeepalive").and_then(|s| s.parse().ok());
|
||||
|
||||
let dns = interface.get("DNS").cloned();
|
||||
let mtu = interface.get("MTU").and_then(|s| s.parse().ok());
|
||||
let preshared_key = peer.get("PresharedKey").cloned();
|
||||
|
||||
Ok(WireGuardConfig {
|
||||
private_key,
|
||||
address,
|
||||
dns,
|
||||
mtu,
|
||||
peer_public_key,
|
||||
peer_endpoint,
|
||||
allowed_ips,
|
||||
persistent_keepalive,
|
||||
preshared_key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an OpenVPN configuration file
|
||||
pub fn parse_openvpn_config(content: &str) -> Result<OpenVpnConfig, VpnError> {
|
||||
let mut remote_host = String::new();
|
||||
let mut remote_port: u16 = 1194; // Default OpenVPN port
|
||||
let mut protocol = "udp".to_string();
|
||||
let mut dev_type = "tun".to_string();
|
||||
|
||||
let has_inline_ca = content.contains("<ca>") && content.contains("</ca>");
|
||||
let has_inline_cert = content.contains("<cert>") && content.contains("</cert>");
|
||||
let has_inline_key = content.contains("<key>") && content.contains("</key>");
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"remote" => {
|
||||
if parts.len() >= 2 {
|
||||
remote_host = parts[1].to_string();
|
||||
}
|
||||
if parts.len() >= 3 {
|
||||
if let Ok(port) = parts[2].parse() {
|
||||
remote_port = port;
|
||||
}
|
||||
}
|
||||
if parts.len() >= 4 {
|
||||
protocol = parts[3].to_string();
|
||||
}
|
||||
}
|
||||
"proto" => {
|
||||
if parts.len() >= 2 {
|
||||
protocol = parts[1].to_string();
|
||||
}
|
||||
}
|
||||
"port" => {
|
||||
if parts.len() >= 2 {
|
||||
if let Ok(port) = parts[1].parse() {
|
||||
remote_port = port;
|
||||
}
|
||||
}
|
||||
}
|
||||
"dev" => {
|
||||
if parts.len() >= 2 {
|
||||
dev_type = parts[1].to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remote_host.is_empty() {
|
||||
return Err(VpnError::InvalidOpenVpn(
|
||||
"Missing 'remote' directive".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(OpenVpnConfig {
|
||||
raw_config: content.to_string(),
|
||||
remote_host,
|
||||
remote_port,
|
||||
protocol,
|
||||
dev_type,
|
||||
has_inline_ca,
|
||||
has_inline_cert,
|
||||
has_inline_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_wireguard_by_extension() {
|
||||
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = test";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "test.conf").unwrap(),
|
||||
VpnType::WireGuard
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "test.ovpn").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_wireguard_by_content() {
|
||||
let content = "[Interface]\nPrivateKey = testkey123\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = peerkey456\nEndpoint = vpn.example.com:51820";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "config").unwrap(),
|
||||
VpnType::WireGuard
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_openvpn_by_content() {
|
||||
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
|
||||
assert_eq!(
|
||||
detect_vpn_type(content, "config").unwrap(),
|
||||
VpnType::OpenVPN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_unknown_format() {
|
||||
let content = "random text that is not a vpn config";
|
||||
assert!(detect_vpn_type(content, "random.txt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wireguard_config() {
|
||||
let content = r#"
|
||||
[Interface]
|
||||
PrivateKey = WGTestPrivateKey123456789012345678901234567890
|
||||
Address = 10.0.0.2/24
|
||||
DNS = 1.1.1.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = WGTestPublicKey1234567890123456789012345678901
|
||||
Endpoint = vpn.example.com:51820
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
PersistentKeepalive = 25
|
||||
"#;
|
||||
|
||||
let config = parse_wireguard_config(content).unwrap();
|
||||
assert_eq!(
|
||||
config.private_key,
|
||||
"WGTestPrivateKey123456789012345678901234567890"
|
||||
);
|
||||
assert_eq!(config.address, "10.0.0.2/24");
|
||||
assert_eq!(config.dns, Some("1.1.1.1".to_string()));
|
||||
assert_eq!(config.mtu, Some(1420));
|
||||
assert_eq!(
|
||||
config.peer_public_key,
|
||||
"WGTestPublicKey1234567890123456789012345678901"
|
||||
);
|
||||
assert_eq!(config.peer_endpoint, "vpn.example.com:51820");
|
||||
assert_eq!(config.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
|
||||
assert_eq!(config.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wireguard_config_minimal() {
|
||||
let content = r#"
|
||||
[Interface]
|
||||
PrivateKey = minimalkey
|
||||
Address = 10.0.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = peerpubkey
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
|
||||
let config = parse_wireguard_config(content).unwrap();
|
||||
assert_eq!(config.private_key, "minimalkey");
|
||||
assert_eq!(config.address, "10.0.0.2/32");
|
||||
assert!(config.dns.is_none());
|
||||
assert!(config.mtu.is_none());
|
||||
assert_eq!(config.peer_public_key, "peerpubkey");
|
||||
assert_eq!(config.peer_endpoint, "1.2.3.4:51820");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wireguard_missing_private_key() {
|
||||
let content = r#"
|
||||
[Interface]
|
||||
Address = 10.0.0.2/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = key
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
|
||||
let result = parse_wireguard_config(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("PrivateKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...cert data...
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...key data...
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194);
|
||||
assert_eq!(config.protocol, "udp");
|
||||
assert_eq!(config.dev_type, "tun");
|
||||
assert!(config.has_inline_ca);
|
||||
assert!(config.has_inline_cert);
|
||||
assert!(config.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_minimal() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 1194); // Default
|
||||
assert_eq!(config.protocol, "udp"); // Default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_config_with_port_and_proto() {
|
||||
let content = r#"
|
||||
client
|
||||
remote vpn.example.com 443 tcp
|
||||
"#;
|
||||
|
||||
let config = parse_openvpn_config(content).unwrap();
|
||||
assert_eq!(config.remote_host, "vpn.example.com");
|
||||
assert_eq!(config.remote_port, 443);
|
||||
assert_eq!(config.protocol, "tcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openvpn_missing_remote() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
|
||||
let result = parse_openvpn_config(content);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("remote"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! VPN support module for WireGuard and OpenVPN configurations.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files)
|
||||
//! - Encrypted storage for VPN configurations
|
||||
//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management
|
||||
|
||||
mod config;
|
||||
mod openvpn;
|
||||
mod storage;
|
||||
mod tunnel;
|
||||
mod wireguard;
|
||||
|
||||
pub use config::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnError, VpnImportResult, VpnStatus, VpnType, WireGuardConfig,
|
||||
};
|
||||
pub use openvpn::OpenVpnTunnel;
|
||||
pub use storage::VpnStorage;
|
||||
pub use tunnel::{TunnelManager, VpnTunnel};
|
||||
pub use wireguard::WireGuardTunnel;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Global VPN storage instance
|
||||
pub static VPN_STORAGE: Lazy<Mutex<VpnStorage>> = Lazy::new(|| Mutex::new(VpnStorage::new()));
|
||||
|
||||
/// Global tunnel manager instance
|
||||
pub static TUNNEL_MANAGER: Lazy<tokio::sync::Mutex<TunnelManager>> =
|
||||
Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new()));
|
||||
@@ -0,0 +1,343 @@
|
||||
//! OpenVPN tunnel implementation using system openvpn binary.
|
||||
|
||||
use super::config::{OpenVpnConfig, VpnError, VpnStatus};
|
||||
use super::tunnel::VpnTunnel;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// OpenVPN tunnel implementation
|
||||
pub struct OpenVpnTunnel {
|
||||
vpn_id: String,
|
||||
config: OpenVpnConfig,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
config_file: Option<NamedTempFile>,
|
||||
connected: AtomicBool,
|
||||
connected_at: Option<i64>,
|
||||
bytes_sent: AtomicU64,
|
||||
bytes_received: AtomicU64,
|
||||
}
|
||||
|
||||
impl OpenVpnTunnel {
|
||||
/// Create a new OpenVPN tunnel
|
||||
pub fn new(vpn_id: String, config: OpenVpnConfig) -> Self {
|
||||
Self {
|
||||
vpn_id,
|
||||
config,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
config_file: None,
|
||||
connected: AtomicBool::new(false),
|
||||
connected_at: None,
|
||||
bytes_sent: AtomicU64::new(0),
|
||||
bytes_received: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the openvpn binary
|
||||
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
// Check common locations
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find via which/where command
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Write config to temporary file
|
||||
fn write_config_file(&mut self) -> Result<PathBuf, VpnError> {
|
||||
let temp_file =
|
||||
NamedTempFile::new().map_err(|e| VpnError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
std::fs::write(temp_file.path(), &self.config.raw_config).map_err(VpnError::Io)?;
|
||||
|
||||
let path = temp_file.path().to_path_buf();
|
||||
self.config_file = Some(temp_file);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Start the OpenVPN process
|
||||
async fn start_process(&mut self) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let config_path = self.write_config_file()?;
|
||||
|
||||
log::info!(
|
||||
"[vpn] Starting OpenVPN with config: {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
// Build command with common options
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg("--verb")
|
||||
.arg("3") // Verbosity level
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Unix, try to avoid requiring root if possible
|
||||
#[cfg(unix)]
|
||||
{
|
||||
cmd.arg("--script-security").arg("2");
|
||||
}
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
*self.process.lock().await = Some(child);
|
||||
|
||||
// Wait a bit and check if process is still running
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
let mut process_guard = self.process.lock().await;
|
||||
if let Some(ref mut child) = *process_guard {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Process exited early
|
||||
let mut error_msg = format!("OpenVPN exited with status: {status}");
|
||||
|
||||
// Try to get stderr output
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let lines: Vec<String> = reader.lines().map_while(Result::ok).take(5).collect();
|
||||
if !lines.is_empty() {
|
||||
error_msg.push_str(&format!("\nError: {}", lines.join("\n")));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(error_msg));
|
||||
}
|
||||
Ok(None) => {
|
||||
// Still running, good
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check process status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kill the OpenVPN process
|
||||
async fn kill_process(&mut self) -> Result<(), VpnError> {
|
||||
let mut process_guard = self.process.lock().await;
|
||||
|
||||
if let Some(mut child) = process_guard.take() {
|
||||
// Try graceful shutdown first
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
if let Ok(pid) = child.id().try_into() {
|
||||
let _ = kill(Pid::from_raw(pid), Signal::SIGTERM);
|
||||
// Wait a bit for graceful shutdown
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
// Clean up config file
|
||||
self.config_file = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpnTunnel for OpenVpnTunnel {
|
||||
async fn connect(&mut self) -> Result<(), VpnError> {
|
||||
if self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Start OpenVPN process
|
||||
self.start_process().await?;
|
||||
|
||||
// Wait for connection to be established
|
||||
// Note: In a real implementation, we'd monitor the OpenVPN management interface
|
||||
// For now, we assume success if the process starts and runs for a bit
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Check if process is still running
|
||||
let process_guard = self.process.lock().await;
|
||||
if let Some(ref child) = *process_guard {
|
||||
let id = child.id();
|
||||
if id > 0 {
|
||||
self.connected.store(true, Ordering::Release);
|
||||
self.connected_at = Some(Utc::now().timestamp());
|
||||
log::info!("[vpn] OpenVPN tunnel {} connected (PID: {id})", self.vpn_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"Failed to establish OpenVPN connection".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError> {
|
||||
if !self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.kill_process().await?;
|
||||
|
||||
self.connected.store(false, Ordering::Release);
|
||||
self.connected_at = None;
|
||||
|
||||
log::info!("[vpn] OpenVPN tunnel {} disconnected", self.vpn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.vpn_id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.is_connected(),
|
||||
vpn_id: self.vpn_id.clone(),
|
||||
connected_at: self.connected_at,
|
||||
bytes_sent: Some(self.bytes_sent.load(Ordering::Relaxed)),
|
||||
bytes_received: Some(self.bytes_received.load(Ordering::Relaxed)),
|
||||
last_handshake: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
self.bytes_sent.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
self.bytes_received.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenVpnTunnel {
|
||||
fn drop(&mut self) {
|
||||
// Clean up process on drop (synchronously)
|
||||
if let Ok(mut guard) = self.process.try_lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> OpenVpnConfig {
|
||||
OpenVpnConfig {
|
||||
raw_config: "client\nremote test.example.com 1194\ndev tun".to_string(),
|
||||
remote_host: "test.example.com".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_tunnel_creation() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-1".to_string(), config);
|
||||
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn-1");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_status() {
|
||||
let config = create_test_config();
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn-2".to_string(), config);
|
||||
|
||||
let status = tunnel.get_status();
|
||||
assert!(!status.connected);
|
||||
assert_eq!(status.vpn_id, "test-ovpn-2");
|
||||
assert!(status.connected_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
// This test just checks that the function doesn't panic
|
||||
// It may or may not find openvpn depending on the system
|
||||
let result = OpenVpnTunnel::find_openvpn_binary();
|
||||
// Just check that it returns a valid Result
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Encrypted storage for VPN configurations.
|
||||
|
||||
use super::config::{VpnConfig, VpnError, VpnType};
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Storage format version for migration support
|
||||
const STORAGE_VERSION: u32 = 1;
|
||||
|
||||
/// Stored VPN configs container
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VpnStorageData {
|
||||
version: u32,
|
||||
configs: Vec<StoredVpnConfig>,
|
||||
}
|
||||
|
||||
/// Encrypted VPN config as stored on disk
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct StoredVpnConfig {
|
||||
id: String,
|
||||
name: String,
|
||||
vpn_type: VpnType,
|
||||
encrypted_data: String, // Base64 encoded encrypted config
|
||||
nonce: String, // Base64 encoded nonce
|
||||
created_at: i64,
|
||||
last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// VPN storage manager with encryption
|
||||
pub struct VpnStorage {
|
||||
storage_path: PathBuf,
|
||||
encryption_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl Default for VpnStorage {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl VpnStorage {
|
||||
/// Create a new VPN storage manager
|
||||
pub fn new() -> Self {
|
||||
let storage_path = Self::get_storage_path();
|
||||
let encryption_key = Self::get_or_create_key();
|
||||
|
||||
Self {
|
||||
storage_path,
|
||||
encryption_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the storage file path
|
||||
fn get_storage_path() -> PathBuf {
|
||||
let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if !data_dir.exists() {
|
||||
let _ = fs::create_dir_all(&data_dir);
|
||||
}
|
||||
|
||||
data_dir.join("vpn_configs.json")
|
||||
}
|
||||
|
||||
/// Get or create the encryption key
|
||||
fn get_or_create_key() -> [u8; 32] {
|
||||
let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().join(".vpn_key"))
|
||||
.unwrap_or_else(|| PathBuf::from(".vpn_key"));
|
||||
|
||||
if key_path.exists() {
|
||||
if let Ok(key_data) = fs::read(&key_path) {
|
||||
if key_data.len() == 32 {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_data);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new key
|
||||
let key: [u8; 32] = rand::rng().random();
|
||||
let _ = fs::write(&key_path, key);
|
||||
|
||||
// Set restrictive permissions on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
/// Load storage data from disk
|
||||
fn load_storage(&self) -> Result<VpnStorageData, VpnError> {
|
||||
if !self.storage_path.exists() {
|
||||
return Ok(VpnStorageData {
|
||||
version: STORAGE_VERSION,
|
||||
configs: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&self.storage_path)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to read storage file: {e}")))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))
|
||||
}
|
||||
|
||||
/// Save storage data to disk
|
||||
fn save_storage(&self, data: &VpnStorageData) -> Result<(), VpnError> {
|
||||
let content = serde_json::to_string_pretty(data)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to serialize storage: {e}")))?;
|
||||
|
||||
fs::write(&self.storage_path, content)
|
||||
.map_err(|e| VpnError::Storage(format!("Failed to write storage file: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt config data
|
||||
fn encrypt(&self, data: &str) -> Result<(String, String), VpnError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| VpnError::Encryption(format!("Failed to create cipher: {e}")))?;
|
||||
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, data.as_bytes())
|
||||
.map_err(|e| VpnError::Encryption(format!("Encryption failed: {e}")))?;
|
||||
|
||||
Ok((
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &ciphertext),
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes),
|
||||
))
|
||||
}
|
||||
|
||||
/// Decrypt config data
|
||||
fn decrypt(&self, encrypted_data: &str, nonce_str: &str) -> Result<String, VpnError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| VpnError::Encryption(format!("Failed to create cipher: {e}")))?;
|
||||
|
||||
let ciphertext =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encrypted_data)
|
||||
.map_err(|e| VpnError::Encryption(format!("Failed to decode ciphertext: {e}")))?;
|
||||
|
||||
let nonce_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, nonce_str)
|
||||
.map_err(|e| VpnError::Encryption(format!("Failed to decode nonce: {e}")))?;
|
||||
|
||||
if nonce_bytes.len() != 12 {
|
||||
return Err(VpnError::Encryption("Invalid nonce length".to_string()));
|
||||
}
|
||||
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext.as_ref())
|
||||
.map_err(|e| VpnError::Encryption(format!("Decryption failed: {e}")))?;
|
||||
|
||||
String::from_utf8(plaintext)
|
||||
.map_err(|e| VpnError::Encryption(format!("Failed to decode plaintext: {e}")))
|
||||
}
|
||||
|
||||
/// Save a VPN configuration
|
||||
pub fn save_config(&self, config: &VpnConfig) -> Result<(), VpnError> {
|
||||
let mut storage = self.load_storage()?;
|
||||
|
||||
// Encrypt the config data
|
||||
let (encrypted_data, nonce) = self.encrypt(&config.config_data)?;
|
||||
|
||||
let stored = StoredVpnConfig {
|
||||
id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
vpn_type: config.vpn_type,
|
||||
encrypted_data,
|
||||
nonce,
|
||||
created_at: config.created_at,
|
||||
last_used: config.last_used,
|
||||
};
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(pos) = storage.configs.iter().position(|c| c.id == config.id) {
|
||||
storage.configs[pos] = stored;
|
||||
} else {
|
||||
storage.configs.push(stored);
|
||||
}
|
||||
|
||||
self.save_storage(&storage)
|
||||
}
|
||||
|
||||
/// Load a VPN configuration by ID
|
||||
pub fn load_config(&self, id: &str) -> Result<VpnConfig, VpnError> {
|
||||
let storage = self.load_storage()?;
|
||||
|
||||
let stored = storage
|
||||
.configs
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.ok_or_else(|| VpnError::NotFound(id.to_string()))?;
|
||||
|
||||
let config_data = self.decrypt(&stored.encrypted_data, &stored.nonce)?;
|
||||
|
||||
Ok(VpnConfig {
|
||||
id: stored.id.clone(),
|
||||
name: stored.name.clone(),
|
||||
vpn_type: stored.vpn_type,
|
||||
config_data,
|
||||
created_at: stored.created_at,
|
||||
last_used: stored.last_used,
|
||||
})
|
||||
}
|
||||
|
||||
/// List all VPN configurations (without decrypted config data)
|
||||
pub fn list_configs(&self) -> Result<Vec<VpnConfig>, VpnError> {
|
||||
let storage = self.load_storage()?;
|
||||
|
||||
Ok(
|
||||
storage
|
||||
.configs
|
||||
.iter()
|
||||
.map(|stored| VpnConfig {
|
||||
id: stored.id.clone(),
|
||||
name: stored.name.clone(),
|
||||
vpn_type: stored.vpn_type,
|
||||
config_data: String::new(), // Don't include config data in list
|
||||
created_at: stored.created_at,
|
||||
last_used: stored.last_used,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Delete a VPN configuration
|
||||
pub fn delete_config(&self, id: &str) -> Result<(), VpnError> {
|
||||
let mut storage = self.load_storage()?;
|
||||
|
||||
let initial_len = storage.configs.len();
|
||||
storage.configs.retain(|c| c.id != id);
|
||||
|
||||
if storage.configs.len() == initial_len {
|
||||
return Err(VpnError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
self.save_storage(&storage)
|
||||
}
|
||||
|
||||
/// Update last_used timestamp
|
||||
pub fn update_last_used(&self, id: &str) -> Result<(), VpnError> {
|
||||
let mut storage = self.load_storage()?;
|
||||
|
||||
if let Some(config) = storage.configs.iter_mut().find(|c| c.id == id) {
|
||||
config.last_used = Some(Utc::now().timestamp());
|
||||
self.save_storage(&storage)
|
||||
} else {
|
||||
Err(VpnError::NotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a VPN config from raw content
|
||||
pub fn import_config(
|
||||
&self,
|
||||
content: &str,
|
||||
filename: &str,
|
||||
name: Option<String>,
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
let vpn_type = super::detect_vpn_type(content, filename)?;
|
||||
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(content)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(content)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let display_name = name.unwrap_or_else(|| {
|
||||
// Generate name from filename
|
||||
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
|
||||
format!("{} ({})", base, vpn_type)
|
||||
});
|
||||
|
||||
let config = VpnConfig {
|
||||
id,
|
||||
name: display_name,
|
||||
vpn_type,
|
||||
config_data: content.to_string(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_storage() -> (VpnStorage, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut storage = VpnStorage::new();
|
||||
storage.storage_path = temp_dir.path().join("test_vpn_configs.json");
|
||||
(storage, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
let original = "This is a secret VPN configuration";
|
||||
|
||||
let (encrypted, nonce) = storage.encrypt(original).unwrap();
|
||||
let decrypted = storage.decrypt(&encrypted, &nonce).unwrap();
|
||||
|
||||
assert_eq!(original, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_config() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
|
||||
let config = VpnConfig {
|
||||
id: "test-id-123".to_string(),
|
||||
name: "Test VPN".to_string(),
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer".to_string(),
|
||||
created_at: 1234567890,
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
let loaded = storage.load_config("test-id-123").unwrap();
|
||||
|
||||
assert_eq!(loaded.id, config.id);
|
||||
assert_eq!(loaded.name, config.name);
|
||||
assert_eq!(loaded.vpn_type, config.vpn_type);
|
||||
assert_eq!(loaded.config_data, config.config_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_configs() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
|
||||
let config1 = VpnConfig {
|
||||
id: "id-1".to_string(),
|
||||
name: "VPN 1".to_string(),
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "secret1".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
let config2 = VpnConfig {
|
||||
id: "id-2".to_string(),
|
||||
name: "VPN 2".to_string(),
|
||||
vpn_type: VpnType::OpenVPN,
|
||||
config_data: "secret2".to_string(),
|
||||
created_at: 2000,
|
||||
last_used: Some(3000),
|
||||
};
|
||||
|
||||
storage.save_config(&config1).unwrap();
|
||||
storage.save_config(&config2).unwrap();
|
||||
|
||||
let configs = storage.list_configs().unwrap();
|
||||
assert_eq!(configs.len(), 2);
|
||||
|
||||
// Config data should be empty in listing
|
||||
assert!(configs[0].config_data.is_empty());
|
||||
assert!(configs[1].config_data.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_config() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
|
||||
let config = VpnConfig {
|
||||
id: "delete-me".to_string(),
|
||||
name: "To Delete".to_string(),
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "data".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
assert!(storage.load_config("delete-me").is_ok());
|
||||
|
||||
storage.delete_config("delete-me").unwrap();
|
||||
assert!(storage.load_config("delete-me").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_config() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
let result = storage.load_config("nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! VPN tunnel trait and management.
|
||||
|
||||
use super::config::{VpnError, VpnStatus};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Trait for VPN tunnel implementations
|
||||
#[async_trait]
|
||||
pub trait VpnTunnel: Send + Sync {
|
||||
/// Connect the VPN tunnel
|
||||
async fn connect(&mut self) -> Result<(), VpnError>;
|
||||
|
||||
/// Disconnect the VPN tunnel
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError>;
|
||||
|
||||
/// Check if the tunnel is connected
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// Get the VPN config ID
|
||||
fn vpn_id(&self) -> &str;
|
||||
|
||||
/// Get the current status of the tunnel
|
||||
fn get_status(&self) -> VpnStatus;
|
||||
|
||||
/// Get bytes sent through the tunnel
|
||||
fn bytes_sent(&self) -> u64;
|
||||
|
||||
/// Get bytes received through the tunnel
|
||||
fn bytes_received(&self) -> u64;
|
||||
}
|
||||
|
||||
/// Manager for active VPN tunnels
|
||||
pub struct TunnelManager {
|
||||
active_tunnels: HashMap<String, Box<dyn VpnTunnel>>,
|
||||
}
|
||||
|
||||
impl Default for TunnelManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelManager {
|
||||
/// Create a new tunnel manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_tunnels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an active tunnel
|
||||
pub fn register_tunnel(&mut self, vpn_id: String, tunnel: Box<dyn VpnTunnel>) {
|
||||
self.active_tunnels.insert(vpn_id, tunnel);
|
||||
}
|
||||
|
||||
/// Remove a tunnel from management
|
||||
pub fn remove_tunnel(&mut self, vpn_id: &str) -> Option<Box<dyn VpnTunnel>> {
|
||||
self.active_tunnels.remove(vpn_id)
|
||||
}
|
||||
|
||||
/// Get a reference to an active tunnel
|
||||
pub fn get_tunnel(&self, vpn_id: &str) -> Option<&dyn VpnTunnel> {
|
||||
self.active_tunnels.get(vpn_id).map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// Get a mutable reference to an active tunnel
|
||||
pub fn get_tunnel_mut(&mut self, vpn_id: &str) -> Option<&mut Box<dyn VpnTunnel>> {
|
||||
self.active_tunnels.get_mut(vpn_id)
|
||||
}
|
||||
|
||||
/// Check if a tunnel is active
|
||||
pub fn is_tunnel_active(&self, vpn_id: &str) -> bool {
|
||||
self
|
||||
.active_tunnels
|
||||
.get(vpn_id)
|
||||
.is_some_and(|t| t.is_connected())
|
||||
}
|
||||
|
||||
/// Get status of all active tunnels
|
||||
pub fn get_all_statuses(&self) -> Vec<VpnStatus> {
|
||||
self
|
||||
.active_tunnels
|
||||
.values()
|
||||
.map(|t| t.get_status())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Disconnect all active tunnels
|
||||
pub async fn disconnect_all(&mut self) -> Vec<Result<(), VpnError>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tunnel in self.active_tunnels.values_mut() {
|
||||
results.push(tunnel.disconnect().await);
|
||||
}
|
||||
|
||||
self.active_tunnels.clear();
|
||||
results
|
||||
}
|
||||
|
||||
/// Get the number of active tunnels
|
||||
pub fn active_count(&self) -> usize {
|
||||
self
|
||||
.active_tunnels
|
||||
.values()
|
||||
.filter(|t| t.is_connected())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// List IDs of all active VPN connections
|
||||
pub fn list_active_ids(&self) -> Vec<String> {
|
||||
self
|
||||
.active_tunnels
|
||||
.iter()
|
||||
.filter(|(_, t)| t.is_connected())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockTunnel {
|
||||
id: String,
|
||||
connected: bool,
|
||||
bytes_sent: u64,
|
||||
bytes_received: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpnTunnel for MockTunnel {
|
||||
async fn connect(&mut self) -> Result<(), VpnError> {
|
||||
self.connected = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError> {
|
||||
self.connected = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.connected,
|
||||
vpn_id: self.id.clone(),
|
||||
connected_at: if self.connected { Some(1000) } else { None },
|
||||
bytes_sent: Some(self.bytes_sent),
|
||||
bytes_received: Some(self.bytes_received),
|
||||
last_handshake: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
self.bytes_sent
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
self.bytes_received
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tunnel_manager_register() {
|
||||
let mut manager = TunnelManager::new();
|
||||
let tunnel = Box::new(MockTunnel {
|
||||
id: "test-1".to_string(),
|
||||
connected: true,
|
||||
bytes_sent: 100,
|
||||
bytes_received: 200,
|
||||
});
|
||||
|
||||
manager.register_tunnel("test-1".to_string(), tunnel);
|
||||
assert!(manager.is_tunnel_active("test-1"));
|
||||
assert!(!manager.is_tunnel_active("test-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tunnel_manager_remove() {
|
||||
let mut manager = TunnelManager::new();
|
||||
let tunnel = Box::new(MockTunnel {
|
||||
id: "test-1".to_string(),
|
||||
connected: true,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
});
|
||||
|
||||
manager.register_tunnel("test-1".to_string(), tunnel);
|
||||
assert!(manager.is_tunnel_active("test-1"));
|
||||
|
||||
let removed = manager.remove_tunnel("test-1");
|
||||
assert!(removed.is_some());
|
||||
assert!(!manager.is_tunnel_active("test-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tunnel_manager_active_count() {
|
||||
let mut manager = TunnelManager::new();
|
||||
|
||||
let tunnel1 = Box::new(MockTunnel {
|
||||
id: "t1".to_string(),
|
||||
connected: true,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
});
|
||||
|
||||
let tunnel2 = Box::new(MockTunnel {
|
||||
id: "t2".to_string(),
|
||||
connected: false,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
});
|
||||
|
||||
manager.register_tunnel("t1".to_string(), tunnel1);
|
||||
manager.register_tunnel("t2".to_string(), tunnel2);
|
||||
|
||||
assert_eq!(manager.active_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tunnel_manager_disconnect_all() {
|
||||
let mut manager = TunnelManager::new();
|
||||
|
||||
let tunnel1 = Box::new(MockTunnel {
|
||||
id: "t1".to_string(),
|
||||
connected: true,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
});
|
||||
|
||||
let tunnel2 = Box::new(MockTunnel {
|
||||
id: "t2".to_string(),
|
||||
connected: true,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
});
|
||||
|
||||
manager.register_tunnel("t1".to_string(), tunnel1);
|
||||
manager.register_tunnel("t2".to_string(), tunnel2);
|
||||
|
||||
assert_eq!(manager.active_count(), 2);
|
||||
|
||||
let results = manager.disconnect_all().await;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|r| r.is_ok()));
|
||||
assert_eq!(manager.active_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! WireGuard tunnel implementation using boringtun.
|
||||
|
||||
use super::config::{VpnError, VpnStatus, WireGuardConfig};
|
||||
use super::tunnel::VpnTunnel;
|
||||
use async_trait::async_trait;
|
||||
use boringtun::noise::{Tunn, TunnResult};
|
||||
use boringtun::x25519::{PublicKey, StaticSecret};
|
||||
use chrono::Utc;
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// WireGuard tunnel implementation
|
||||
pub struct WireGuardTunnel {
|
||||
vpn_id: String,
|
||||
config: WireGuardConfig,
|
||||
tunnel: Option<Arc<Mutex<Box<Tunn>>>>,
|
||||
socket: Option<Arc<UdpSocket>>,
|
||||
connected: AtomicBool,
|
||||
connected_at: Option<i64>,
|
||||
bytes_sent: AtomicU64,
|
||||
bytes_received: AtomicU64,
|
||||
last_handshake: Option<i64>,
|
||||
peer_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl WireGuardTunnel {
|
||||
/// Create a new WireGuard tunnel
|
||||
pub fn new(vpn_id: String, config: WireGuardConfig) -> Self {
|
||||
Self {
|
||||
vpn_id,
|
||||
config,
|
||||
tunnel: None,
|
||||
socket: None,
|
||||
connected: AtomicBool::new(false),
|
||||
connected_at: None,
|
||||
bytes_sent: AtomicU64::new(0),
|
||||
bytes_received: AtomicU64::new(0),
|
||||
last_handshake: None,
|
||||
peer_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse base64 key to bytes
|
||||
fn parse_key(key: &str) -> Result<[u8; 32], VpnError> {
|
||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key)
|
||||
.map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?;
|
||||
|
||||
if decoded.len() != 32 {
|
||||
return Err(VpnError::InvalidWireGuard(format!(
|
||||
"Invalid key length: {} (expected 32)",
|
||||
decoded.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut key_bytes = [0u8; 32];
|
||||
key_bytes.copy_from_slice(&decoded);
|
||||
Ok(key_bytes)
|
||||
}
|
||||
|
||||
/// Initialize the WireGuard tunnel
|
||||
fn init_tunnel(&mut self) -> Result<(), VpnError> {
|
||||
// Parse private key
|
||||
let private_key_bytes = Self::parse_key(&self.config.private_key)?;
|
||||
let static_private = StaticSecret::from(private_key_bytes);
|
||||
|
||||
// Parse peer public key
|
||||
let peer_public_bytes = Self::parse_key(&self.config.peer_public_key)?;
|
||||
let peer_public = PublicKey::from(peer_public_bytes);
|
||||
|
||||
// Parse optional preshared key
|
||||
let preshared_key = if let Some(ref psk) = self.config.preshared_key {
|
||||
Some(Self::parse_key(psk)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create the boringtun tunnel
|
||||
let tunn = Tunn::new(
|
||||
static_private,
|
||||
peer_public,
|
||||
preshared_key,
|
||||
self.config.persistent_keepalive,
|
||||
0, // index
|
||||
None,
|
||||
)
|
||||
.map_err(|e| VpnError::Tunnel(format!("Failed to create tunnel: {e}")))?;
|
||||
|
||||
self.tunnel = Some(Arc::new(Mutex::new(Box::new(tunn))));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve peer endpoint to socket address
|
||||
fn resolve_endpoint(&mut self) -> Result<SocketAddr, VpnError> {
|
||||
let endpoint = &self.config.peer_endpoint;
|
||||
|
||||
// Try to resolve the endpoint
|
||||
let addrs: Vec<SocketAddr> = endpoint
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to resolve endpoint '{endpoint}': {e}")))?
|
||||
.collect();
|
||||
|
||||
addrs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| VpnError::Connection(format!("No addresses found for endpoint: {endpoint}")))
|
||||
}
|
||||
|
||||
/// Perform WireGuard handshake
|
||||
async fn handshake(&mut self) -> Result<(), VpnError> {
|
||||
let tunnel = self
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
|
||||
|
||||
let socket = self
|
||||
.socket
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
|
||||
|
||||
let peer_addr = self
|
||||
.peer_addr
|
||||
.ok_or_else(|| VpnError::Tunnel("Peer address not resolved".to_string()))?;
|
||||
|
||||
let mut tunnel_guard = tunnel.lock().await;
|
||||
|
||||
// Generate handshake initiation
|
||||
let mut dst = vec![0u8; 2048];
|
||||
let result = tunnel_guard.format_handshake_initiation(&mut dst, false);
|
||||
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(packet) => {
|
||||
socket
|
||||
.send_to(packet, peer_addr)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
|
||||
|
||||
self
|
||||
.bytes_sent
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
return Err(VpnError::Tunnel(format!(
|
||||
"Handshake initiation failed: {e:?}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Wait for handshake response (with timeout)
|
||||
socket
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
|
||||
|
||||
let mut recv_buf = vec![0u8; 2048];
|
||||
|
||||
match socket.recv_from(&mut recv_buf) {
|
||||
Ok((len, _from)) => {
|
||||
self.bytes_received.fetch_add(len as u64, Ordering::Relaxed);
|
||||
|
||||
let result = tunnel_guard.decapsulate(None, &recv_buf[..len], &mut dst);
|
||||
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(response) => {
|
||||
socket
|
||||
.send_to(response, peer_addr)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
|
||||
|
||||
self
|
||||
.bytes_sent
|
||||
.fetch_add(response.len() as u64, Ordering::Relaxed);
|
||||
self.last_handshake = Some(Utc::now().timestamp());
|
||||
}
|
||||
TunnResult::Done => {
|
||||
self.last_handshake = Some(Utc::now().timestamp());
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
return Err(VpnError::Tunnel(format!(
|
||||
"Handshake response failed: {e:?}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Handshake timeout or error: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt and send data through the tunnel
|
||||
pub async fn send(&self, data: &[u8]) -> Result<(), VpnError> {
|
||||
let tunnel = self
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
|
||||
|
||||
let socket = self
|
||||
.socket
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
|
||||
|
||||
let peer_addr = self
|
||||
.peer_addr
|
||||
.ok_or_else(|| VpnError::Tunnel("Peer address not resolved".to_string()))?;
|
||||
|
||||
let mut tunnel_guard = tunnel.lock().await;
|
||||
let mut dst = vec![0u8; data.len() + 256]; // Extra space for WireGuard overhead
|
||||
|
||||
let result = tunnel_guard.encapsulate(data, &mut dst);
|
||||
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(packet) => {
|
||||
socket
|
||||
.send_to(packet, peer_addr)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to send data: {e}")))?;
|
||||
|
||||
self
|
||||
.bytes_sent
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
return Err(VpnError::Tunnel(format!("Encryption failed: {e:?}")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive and decrypt data from the tunnel
|
||||
pub async fn receive(&self, buf: &mut [u8]) -> Result<usize, VpnError> {
|
||||
let tunnel = self
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Tunnel not initialized".to_string()))?;
|
||||
|
||||
let socket = self
|
||||
.socket
|
||||
.as_ref()
|
||||
.ok_or_else(|| VpnError::Tunnel("Socket not initialized".to_string()))?;
|
||||
|
||||
let mut recv_buf = vec![0u8; 2048];
|
||||
|
||||
let (len, _from) = socket
|
||||
.recv_from(&mut recv_buf)
|
||||
.map_err(|e| VpnError::Connection(format!("Receive failed: {e}")))?;
|
||||
|
||||
self.bytes_received.fetch_add(len as u64, Ordering::Relaxed);
|
||||
|
||||
let mut tunnel_guard = tunnel.lock().await;
|
||||
// decapsulate writes decrypted data directly to buf and returns a slice pointing to it
|
||||
let result = tunnel_guard.decapsulate(None, &recv_buf[..len], buf);
|
||||
|
||||
match result {
|
||||
// Data is already written to buf by decapsulate, just return the length
|
||||
TunnResult::WriteToTunnelV4(decrypted, _) => Ok(decrypted.len()),
|
||||
TunnResult::WriteToTunnelV6(decrypted, _) => Ok(decrypted.len()),
|
||||
TunnResult::Done => Ok(0),
|
||||
TunnResult::Err(e) => Err(VpnError::Tunnel(format!("Decryption failed: {e:?}"))),
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpnTunnel for WireGuardTunnel {
|
||||
async fn connect(&mut self) -> Result<(), VpnError> {
|
||||
if self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Initialize the tunnel
|
||||
self.init_tunnel()?;
|
||||
|
||||
// Resolve endpoint
|
||||
self.peer_addr = Some(self.resolve_endpoint()?);
|
||||
|
||||
// Create UDP socket
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to create socket: {e}")))?;
|
||||
|
||||
socket
|
||||
.set_nonblocking(false)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to set socket options: {e}")))?;
|
||||
|
||||
self.socket = Some(Arc::new(socket));
|
||||
|
||||
// Perform handshake
|
||||
self.handshake().await?;
|
||||
|
||||
self.connected.store(true, Ordering::Release);
|
||||
self.connected_at = Some(Utc::now().timestamp());
|
||||
|
||||
log::info!("[vpn] WireGuard tunnel {} connected", self.vpn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), VpnError> {
|
||||
if !self.connected.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.connected.store(false, Ordering::Release);
|
||||
self.tunnel = None;
|
||||
self.socket = None;
|
||||
self.connected_at = None;
|
||||
|
||||
log::info!("[vpn] WireGuard tunnel {} disconnected", self.vpn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.vpn_id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.is_connected(),
|
||||
vpn_id: self.vpn_id.clone(),
|
||||
connected_at: self.connected_at,
|
||||
bytes_sent: Some(self.bytes_sent.load(Ordering::Relaxed)),
|
||||
bytes_received: Some(self.bytes_received.load(Ordering::Relaxed)),
|
||||
last_handshake: self.last_handshake,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
self.bytes_sent.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
self.bytes_received.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config() -> WireGuardConfig {
|
||||
WireGuardConfig {
|
||||
// These are test keys, not real ones
|
||||
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
mtu: Some(1420),
|
||||
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
|
||||
peer_endpoint: "127.0.0.1:51820".to_string(),
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
preshared_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_tunnel_creation() {
|
||||
let config = create_test_config();
|
||||
let tunnel = WireGuardTunnel::new("test-wg-1".to_string(), config);
|
||||
|
||||
assert_eq!(tunnel.vpn_id(), "test-wg-1");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_valid() {
|
||||
// Valid base64-encoded 32-byte key
|
||||
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
|
||||
let result = WireGuardTunnel::parse_key(key);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_invalid_base64() {
|
||||
let key = "not-valid-base64!!!";
|
||||
let result = WireGuardTunnel::parse_key(key);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_wrong_length() {
|
||||
// Valid base64 but wrong length
|
||||
let key = "YWJjZA=="; // "abcd" in base64
|
||||
let result = WireGuardTunnel::parse_key(key);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_status() {
|
||||
let config = create_test_config();
|
||||
let tunnel = WireGuardTunnel::new("test-wg-2".to_string(), config);
|
||||
|
||||
let status = tunnel.get_status();
|
||||
assert!(!status.connected);
|
||||
assert_eq!(status.vpn_id, "test-wg-2");
|
||||
assert!(status.connected_at.is_none());
|
||||
assert_eq!(status.bytes_sent, Some(0));
|
||||
assert_eq!(status.bytes_received, Some(0));
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,12 @@ impl WayfernTermsManager {
|
||||
timestamp >= MIN_VALID_TIMESTAMP
|
||||
}
|
||||
|
||||
pub fn is_wayfern_downloaded(&self) -> bool {
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
let versions = registry.get_downloaded_versions("wayfern");
|
||||
!versions.is_empty()
|
||||
}
|
||||
|
||||
fn get_any_wayfern_executable(&self) -> Option<PathBuf> {
|
||||
// First try to get executable from any downloaded Wayfern version
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# Sample WireGuard configuration for testing
|
||||
# This is NOT a real configuration - for unit test purposes only
|
||||
|
||||
[Interface]
|
||||
PrivateKey = YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=
|
||||
Address = 10.0.0.2/24
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = vpn.example.com:51820
|
||||
PersistentKeepalive = 25
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
# Sample OpenVPN configuration for testing
|
||||
# This is NOT a real configuration - for unit test purposes only
|
||||
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.example.com 1194
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
verb 3
|
||||
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJaMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2EtZXhhbXBsZTAeFw0yMzAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBa
|
||||
MBkxFzAVBgNVBAMMDnRlc3QtY2EtZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49
|
||||
AwEHA0IABHfakeZYe3R6uCZoL5DqbZkW8mBVKnIYMrIIKV4FPYO9V1YL8V3Z9QC
|
||||
TEST_CERTIFICATE_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
|
||||
<cert>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJbMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM
|
||||
DnRlc3QtY2xpZW50LWV4YW1wbGUwHhcNMjMwMTAxMDAwMDAwWhcNMjUwMTAxMDAw
|
||||
MDAwWjAZMRcwFQYDVQQDDA50ZXN0LWNsaWVudC1leGFtcGxlMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAE
|
||||
TEST_CLIENT_CERT_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END CERTIFICATE-----
|
||||
</cert>
|
||||
|
||||
<key>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZFG/NKjHmTJBNcuH
|
||||
TEST_PRIVATE_KEY_DATA_NOT_REAL_EXAMPLE_ONLY
|
||||
-----END PRIVATE KEY-----
|
||||
</key>
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Test harness for VPN integration tests.
|
||||
//!
|
||||
//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests.
|
||||
//! In CI environments, it uses pre-configured service containers.
|
||||
//! In local development, it spawns Docker containers on demand.
|
||||
//!
|
||||
//! Note: These utilities are available for tests that need Docker containers,
|
||||
//! but may not be used in all test configurations.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest";
|
||||
const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest";
|
||||
const WG_CONTAINER: &str = "donut-wg-test";
|
||||
const OVPN_CONTAINER: &str = "donut-ovpn-test";
|
||||
|
||||
/// Check if running in CI environment
|
||||
pub fn is_ci() -> bool {
|
||||
std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok()
|
||||
}
|
||||
|
||||
/// Check if Docker is available
|
||||
pub fn is_docker_available() -> bool {
|
||||
Command::new("docker")
|
||||
.arg("version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Start a WireGuard test server and return client config
|
||||
pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
if is_ci() {
|
||||
// In CI, use the service container configured in workflow
|
||||
let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into());
|
||||
|
||||
// Wait for service to be ready
|
||||
wait_for_service(&host, port.parse().unwrap_or(51820)).await?;
|
||||
|
||||
return get_ci_wireguard_config(&host, &port);
|
||||
}
|
||||
|
||||
if !is_docker_available() {
|
||||
return Err("Docker is not available for local testing".to_string());
|
||||
}
|
||||
|
||||
// Stop any existing container
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", WG_CONTAINER])
|
||||
.output();
|
||||
|
||||
// Start WireGuard container
|
||||
let output = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
WG_CONTAINER,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
"51820:51820/udp",
|
||||
"-e",
|
||||
"PEERS=1",
|
||||
"-e",
|
||||
"SERVERURL=127.0.0.1",
|
||||
"-e",
|
||||
"SERVERPORT=51820",
|
||||
"-e",
|
||||
"PEERDNS=auto",
|
||||
WIREGUARD_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start WireGuard container: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Docker run failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// Wait for container to be ready and generate configs
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
// Extract client config from container
|
||||
let config_output = Command::new("docker")
|
||||
.args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to get client config: {e}"))?;
|
||||
|
||||
if !config_output.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to read config: {}",
|
||||
String::from_utf8_lossy(&config_output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let config_str = String::from_utf8_lossy(&config_output.stdout).to_string();
|
||||
parse_wireguard_test_config(&config_str)
|
||||
}
|
||||
|
||||
/// Start an OpenVPN test server and return client config
|
||||
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
|
||||
if is_ci() {
|
||||
// In CI, use the service container configured in workflow
|
||||
let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into());
|
||||
|
||||
// Wait for service to be ready
|
||||
wait_for_service(&host, port.parse().unwrap_or(1194)).await?;
|
||||
|
||||
return get_ci_openvpn_config(&host, &port);
|
||||
}
|
||||
|
||||
if !is_docker_available() {
|
||||
return Err("Docker is not available for local testing".to_string());
|
||||
}
|
||||
|
||||
// Stop any existing container
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", OVPN_CONTAINER])
|
||||
.output();
|
||||
|
||||
// For OpenVPN, we need to initialize PKI first, which is complex
|
||||
// For simplicity in tests, we'll use a pre-configured test config
|
||||
Err("OpenVPN container setup requires pre-configured PKI. Use test fixtures instead.".to_string())
|
||||
}
|
||||
|
||||
/// Stop all VPN test servers
|
||||
pub async fn stop_vpn_servers() {
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER])
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Wait for a network service to be ready
|
||||
async fn wait_for_service(host: &str, port: u16) -> Result<(), String> {
|
||||
let timeout = Duration::from_secs(30);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
if std::net::TcpStream::connect(format!("{host}:{port}")).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
Err(format!("Timeout waiting for service at {host}:{port}"))
|
||||
}
|
||||
|
||||
/// WireGuard test configuration
|
||||
pub struct WireGuardTestConfig {
|
||||
pub private_key: String,
|
||||
pub address: String,
|
||||
pub dns: Option<String>,
|
||||
pub peer_public_key: String,
|
||||
pub peer_endpoint: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
}
|
||||
|
||||
/// OpenVPN test configuration
|
||||
pub struct OpenVpnTestConfig {
|
||||
pub raw_config: String,
|
||||
pub remote_host: String,
|
||||
pub remote_port: u16,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Parse WireGuard test config from INI content
|
||||
fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, String> {
|
||||
let mut private_key = String::new();
|
||||
let mut address = String::new();
|
||||
let mut dns = None;
|
||||
let mut peer_public_key = String::new();
|
||||
let mut peer_endpoint = String::new();
|
||||
let mut allowed_ips = vec!["0.0.0.0/0".to_string()];
|
||||
let mut current_section = "";
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "[Interface]" {
|
||||
current_section = "interface";
|
||||
continue;
|
||||
}
|
||||
if line == "[Peer]" {
|
||||
current_section = "peer";
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match (current_section, key) {
|
||||
("interface", "PrivateKey") => private_key = value.to_string(),
|
||||
("interface", "Address") => address = value.to_string(),
|
||||
("interface", "DNS") => dns = Some(value.to_string()),
|
||||
("peer", "PublicKey") => peer_public_key = value.to_string(),
|
||||
("peer", "Endpoint") => peer_endpoint = value.to_string(),
|
||||
("peer", "AllowedIPs") => {
|
||||
allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if private_key.is_empty() || address.is_empty() || peer_public_key.is_empty() {
|
||||
return Err("Invalid WireGuard config: missing required fields".to_string());
|
||||
}
|
||||
|
||||
// Replace Endpoint with localhost for local testing
|
||||
if peer_endpoint.contains("10.") || peer_endpoint.contains("172.") {
|
||||
let port = peer_endpoint.split(':').next_back().unwrap_or("51820");
|
||||
peer_endpoint = format!("127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
Ok(WireGuardTestConfig {
|
||||
private_key,
|
||||
address,
|
||||
dns,
|
||||
peer_public_key,
|
||||
peer_endpoint,
|
||||
allowed_ips,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get WireGuard config from CI environment
|
||||
fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig, String> {
|
||||
// In CI, use environment variables or test fixtures
|
||||
let private_key =
|
||||
std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string());
|
||||
let public_key =
|
||||
std::env::var("VPN_TEST_WG_PUBLIC_KEY").unwrap_or_else(|_| "test-public-key".to_string());
|
||||
|
||||
Ok(WireGuardTestConfig {
|
||||
private_key,
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
peer_public_key: public_key,
|
||||
peer_endpoint: format!("{host}:{port}"),
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
/// Get OpenVPN config from CI environment
|
||||
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
|
||||
let raw_config = format!(
|
||||
r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote {host} {port}
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
"#
|
||||
);
|
||||
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! VPN integration tests
|
||||
//!
|
||||
//! These tests verify VPN config parsing, storage, and tunnel functionality.
|
||||
//! Connection tests require Docker and are skipped if Docker is not available.
|
||||
|
||||
mod test_harness;
|
||||
|
||||
use donutbrowser_lib::vpn::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnStorage, VpnType, WireGuardConfig,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
// ============================================================================
|
||||
// Config Parsing Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_config_import() {
|
||||
let config = include_str!("fixtures/test.conf");
|
||||
let result = parse_wireguard_config(config);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse WireGuard config: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let wg = result.unwrap();
|
||||
assert!(!wg.private_key.is_empty());
|
||||
assert_eq!(wg.address, "10.0.0.2/24");
|
||||
assert_eq!(wg.dns, Some("1.1.1.1".to_string()));
|
||||
assert!(!wg.peer_public_key.is_empty());
|
||||
assert_eq!(wg.peer_endpoint, "vpn.example.com:51820");
|
||||
assert!(wg.allowed_ips.contains(&"0.0.0.0/0".to_string()));
|
||||
assert_eq!(wg.persistent_keepalive, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_import() {
|
||||
let config = include_str!("fixtures/test.ovpn");
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse OpenVPN config: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "vpn.example.com");
|
||||
assert_eq!(ovpn.remote_port, 1194);
|
||||
assert_eq!(ovpn.protocol, "udp");
|
||||
assert_eq!(ovpn.dev_type, "tun");
|
||||
assert!(ovpn.has_inline_ca);
|
||||
assert!(ovpn.has_inline_cert);
|
||||
assert!(ovpn.has_inline_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_extension() {
|
||||
let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer";
|
||||
let result = detect_vpn_type(content, "my-vpn.conf");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_extension() {
|
||||
let content = "client\nremote vpn.example.com 1194";
|
||||
let result = detect_vpn_type(content, "my-vpn.ovpn");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_wireguard_by_content() {
|
||||
let content = r#"
|
||||
[Interface]
|
||||
PrivateKey = somekey
|
||||
Address = 10.0.0.2/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = peerkey
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let result = detect_vpn_type(content, "config.txt");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::WireGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_openvpn_by_content() {
|
||||
let content = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote vpn.server.com 443
|
||||
"#;
|
||||
let result = detect_vpn_type(content, "config.txt");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), VpnType::OpenVPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_vpn_type_unknown() {
|
||||
let content = "this is just some random text that is not a vpn config";
|
||||
let result = detect_vpn_type(content, "random.txt");
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_config_missing_private_key() {
|
||||
let config = r#"
|
||||
[Interface]
|
||||
Address = 10.0.0.2/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = somekey
|
||||
Endpoint = 1.2.3.4:51820
|
||||
"#;
|
||||
let result = parse_wireguard_config(config);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("PrivateKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wireguard_config_missing_peer() {
|
||||
let config = r#"
|
||||
[Interface]
|
||||
PrivateKey = somekey
|
||||
Address = 10.0.0.2/24
|
||||
"#;
|
||||
let result = parse_wireguard_config(config);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("PublicKey") || err.contains("Peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_missing_remote() {
|
||||
let config = r#"
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
"#;
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openvpn_config_with_port_in_remote() {
|
||||
let config = "client\nremote server.example.com 443 tcp";
|
||||
let result = parse_openvpn_config(config);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ovpn = result.unwrap();
|
||||
assert_eq!(ovpn.remote_host, "server.example.com");
|
||||
assert_eq!(ovpn.remote_port, 443);
|
||||
assert_eq!(ovpn.protocol, "tcp");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_save_and_load() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
let config = VpnConfig {
|
||||
id: "test-id-1".to_string(),
|
||||
name: "Test VPN".to_string(),
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "[Interface]\nPrivateKey=key\n[Peer]\nPublicKey=peer".to_string(),
|
||||
created_at: 1234567890,
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
let save_result = storage.save_config(&config);
|
||||
assert!(
|
||||
save_result.is_ok(),
|
||||
"Failed to save config: {:?}",
|
||||
save_result.err()
|
||||
);
|
||||
|
||||
let load_result = storage.load_config("test-id-1");
|
||||
assert!(
|
||||
load_result.is_ok(),
|
||||
"Failed to load config: {:?}",
|
||||
load_result.err()
|
||||
);
|
||||
|
||||
let loaded = load_result.unwrap();
|
||||
assert_eq!(loaded.id, config.id);
|
||||
assert_eq!(loaded.name, config.name);
|
||||
assert_eq!(loaded.vpn_type, config.vpn_type);
|
||||
assert_eq!(loaded.config_data, config.config_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_list() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
// Save two configs
|
||||
for i in 1..=2 {
|
||||
let config = VpnConfig {
|
||||
id: format!("list-test-{i}"),
|
||||
name: format!("VPN {i}"),
|
||||
vpn_type: if i == 1 {
|
||||
VpnType::WireGuard
|
||||
} else {
|
||||
VpnType::OpenVPN
|
||||
},
|
||||
config_data: "secret data".to_string(),
|
||||
created_at: 1000 * i as i64,
|
||||
last_used: None,
|
||||
};
|
||||
storage.save_config(&config).unwrap();
|
||||
}
|
||||
|
||||
let list = storage.list_configs().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
// Config data should be empty in listing
|
||||
for cfg in &list {
|
||||
assert!(cfg.config_data.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_delete() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
let config = VpnConfig {
|
||||
id: "delete-test".to_string(),
|
||||
name: "To Delete".to_string(),
|
||||
vpn_type: VpnType::WireGuard,
|
||||
config_data: "data".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
assert!(storage.load_config("delete-test").is_ok());
|
||||
|
||||
storage.delete_config("delete-test").unwrap();
|
||||
assert!(storage.load_config("delete-test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_vpn_storage_import() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = create_test_storage(&temp_dir);
|
||||
|
||||
let wg_config = include_str!("fixtures/test.conf");
|
||||
let result = storage.import_config(wg_config, "my-vpn.conf", Some("My WireGuard".to_string()));
|
||||
|
||||
assert!(result.is_ok(), "Import failed: {:?}", result.err());
|
||||
|
||||
let imported = result.unwrap();
|
||||
assert_eq!(imported.name, "My WireGuard");
|
||||
assert_eq!(imported.vpn_type, VpnType::WireGuard);
|
||||
assert!(!imported.id.is_empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
fn create_test_storage(_temp_dir: &tempfile::TempDir) -> VpnStorage {
|
||||
// VpnStorage::new() uses the default path
|
||||
// TODO: Pass temp_dir path when VpnStorage supports custom paths
|
||||
VpnStorage::new()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection Tests (require Docker)
|
||||
// ============================================================================
|
||||
|
||||
/// These tests require Docker to be available.
|
||||
/// They are automatically skipped if Docker is not installed.
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_wireguard_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = WireGuardConfig {
|
||||
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
|
||||
address: "10.0.0.2/24".to_string(),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
mtu: None,
|
||||
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
|
||||
peer_endpoint: "127.0.0.1:51820".to_string(),
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||
persistent_keepalive: Some(25),
|
||||
preshared_key: None,
|
||||
};
|
||||
|
||||
use donutbrowser_lib::vpn::{VpnTunnel, WireGuardTunnel};
|
||||
|
||||
let tunnel = WireGuardTunnel::new("test-wg".to_string(), config);
|
||||
assert_eq!(tunnel.vpn_id(), "test-wg");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_tunnel_init() {
|
||||
// This test only verifies tunnel creation, not actual connection
|
||||
let config = OpenVpnConfig {
|
||||
raw_config: "client\nremote localhost 1194".to_string(),
|
||||
remote_host: "localhost".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
dev_type: "tun".to_string(),
|
||||
has_inline_ca: false,
|
||||
has_inline_cert: false,
|
||||
has_inline_key: false,
|
||||
};
|
||||
|
||||
use donutbrowser_lib::vpn::{OpenVpnTunnel, VpnTunnel};
|
||||
|
||||
let tunnel = OpenVpnTunnel::new("test-ovpn".to_string(), config);
|
||||
assert_eq!(tunnel.vpn_id(), "test-ovpn");
|
||||
assert!(!tunnel.is_connected());
|
||||
assert_eq!(tunnel.bytes_sent(), 0);
|
||||
assert_eq!(tunnel.bytes_received(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_tunnel_manager() {
|
||||
use donutbrowser_lib::vpn::{TunnelManager, VpnStatus, VpnTunnel};
|
||||
|
||||
// Create a mock tunnel for testing the manager
|
||||
struct MockTunnel {
|
||||
id: String,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VpnTunnel for MockTunnel {
|
||||
async fn connect(&mut self) -> Result<(), donutbrowser_lib::vpn::VpnError> {
|
||||
self.connected = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), donutbrowser_lib::vpn::VpnError> {
|
||||
self.connected = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
fn vpn_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn get_status(&self) -> VpnStatus {
|
||||
VpnStatus {
|
||||
connected: self.connected,
|
||||
vpn_id: self.id.clone(),
|
||||
connected_at: None,
|
||||
bytes_sent: Some(0),
|
||||
bytes_received: Some(0),
|
||||
last_handshake: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_sent(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
fn bytes_received(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = TunnelManager::new();
|
||||
|
||||
let tunnel = Box::new(MockTunnel {
|
||||
id: "mock-1".to_string(),
|
||||
connected: true,
|
||||
});
|
||||
|
||||
manager.register_tunnel("mock-1".to_string(), tunnel);
|
||||
assert!(manager.is_tunnel_active("mock-1"));
|
||||
assert!(!manager.is_tunnel_active("nonexistent"));
|
||||
assert_eq!(manager.active_count(), 1);
|
||||
|
||||
manager.remove_tunnel("mock-1");
|
||||
assert!(!manager.is_tunnel_active("mock-1"));
|
||||
assert_eq!(manager.active_count(), 0);
|
||||
}
|
||||
|
||||
// NOTE: Actual connection tests require Docker containers running.
|
||||
// These are meant to be run with the CI workflow that sets up service containers.
|
||||
// To run locally: docker run -d --cap-add=NET_ADMIN -p 51820:51820/udp -e PEERS=1 linuxserver/wireguard
|
||||
Reference in New Issue
Block a user