Compare commits

...

18 Commits

Author SHA1 Message Date
zhom 4cfbcde3de chore: version bump 2026-04-27 22:24:40 +04:00
zhom c9ae34f225 fix: correct browser port mapping 2026-04-27 22:24:40 +04:00
github-actions[bot] 0b30939b8f chore: update flake.nix for v0.22.2 [skip ci] (#315)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:48 +00:00
github-actions[bot] 3e99bffe06 docs: update CHANGELOG.md and README.md for v0.22.2 [skip ci] (#314)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 03:57:35 +00:00
zhom 37da41da6c chore: version bump 2026-04-27 06:20:31 +04:00
zhom b5a8a23b55 refactor: cookie management 2026-04-27 06:11:50 +04:00
github-actions[bot] 32888a90b3 chore: update flake.nix for v0.22.1 [skip ci] (#313)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 01:31:19 +00:00
github-actions[bot] 50bf6a0ea1 docs: update CHANGELOG.md and README.md for v0.22.1 [skip ci] (#312)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 01:30:59 +00:00
zhom 3ea80830cf chore: version bump 2026-04-27 03:51:03 +04:00
zhom d453dfb613 chore: linting 2026-04-27 00:44:05 +04:00
zhom bc2bf57908 chore: audit 2026-04-27 00:37:56 +04:00
zhom 18b28ce0cb fix: link proper wayfern tos 2026-04-27 00:26:22 +04:00
zhom ce76c1381f refactor: vpn refresh and remove openvpn support 2026-04-27 00:26:22 +04:00
andy 91218e08f9 Merge pull request #303 from zhom/dependabot/github_actions/github-actions-505fac3765
ci(deps): bump the github-actions group with 3 updates
2026-04-26 22:26:15 +02:00
github-actions[bot] 111b6819f0 chore: update flake.nix for v0.22.0 [skip ci] (#307)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-25 15:31:04 +00:00
github-actions[bot] abc96e7424 docs: update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-25 15:30:46 +00:00
dependabot[bot] d6ef07e98d deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
Bumps the rust-dependencies group with 29 updates in the /src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [tokio](https://github.com/tokio-rs/tokio) | `1.51.1` | `1.52.1` |
| [libc](https://github.com/rust-lang/libc) | `0.2.184` | `0.2.186` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.0` | `1.23.1` |
| [blake3](https://github.com/BLAKE3-team/BLAKE3) | `1.8.4` | `1.8.5` |
| [axum](https://github.com/tokio-rs/axum) | `0.8.8` | `0.8.9` |
| [clap](https://github.com/clap-rs/clap) | `4.6.0` | `4.6.1` |
| [tray-icon](https://github.com/tauri-apps/tray-icon) | `0.22.0` | `0.22.1` |
| [muda](https://github.com/tauri-apps/muda) | `0.17.2` | `0.18.0` |
| [bitstream-io](https://github.com/tuffy/bitstream-io) | `4.9.0` | `4.10.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.60` | `1.2.61` |
| [crc-catalog](https://github.com/akhilles/crc-catalog) | `2.4.0` | `2.5.0` |
| [data-encoding](https://github.com/ia0/data-encoding) | `2.10.0` | `2.11.0` |
| [dbus](https://github.com/diwic/dbus-rs) | `0.9.10` | `0.9.11` |
| [embed-resource](https://github.com/nabijaczleweli/rust-embed-resource) | `3.0.8` | `3.0.9` |
| [hybrid-array](https://github.com/RustCrypto/hybrid-array) | `0.4.10` | `0.4.11` |
| [hyper-rustls](https://github.com/rustls/hyper-rustls) | `0.27.7` | `0.27.9` |
| [jiff](https://github.com/BurntSushi/jiff) | `0.2.23` | `0.2.24` |
| [open](https://github.com/Byron/open-rs) | `5.3.3` | `5.3.4` |
| [openssl](https://github.com/rust-openssl/rust-openssl) | `0.10.76` | `0.10.78` |
| [pkg-config](https://github.com/rust-lang/pkg-config-rs) | `0.3.32` | `0.3.33` |
| [portable-atomic-util](https://github.com/taiki-e/portable-atomic-util) | `0.2.6` | `0.2.7` |
| [pxfm](https://github.com/awxkee/pxfm) | `0.1.28` | `0.1.29` |
| [rayon](https://github.com/rayon-rs/rayon) | `1.11.0` | `1.12.0` |
| [rustls](https://github.com/rustls/rustls) | `0.23.37` | `0.23.39` |
| [rustls-pki-types](https://github.com/rustls/pki-types) | `1.14.0` | `1.14.1` |
| [sqlite-wasm-rs](https://github.com/Spxg/sqlite-wasm-rs) | `0.5.2` | `0.5.3` |
| [wasip2](https://github.com/bytecodealliance/wasi-rs) | `1.0.2+wasi-0.2.9` | `1.0.3+wasi-0.2.9` |
| [web_atoms](https://github.com/servo/html5ever) | `0.2.3` | `0.2.4` |



Updates `tokio` from 1.51.1 to 1.52.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.51.1...tokio-1.52.1)

Updates `libc` from 0.2.184 to 0.2.186
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.186/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.184...0.2.186)

Updates `bzip2` from 0.5.2 to 0.6.1
- [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases)
- [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1)

Updates `uuid` from 1.23.0 to 1.23.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.0...v1.23.1)

Updates `blake3` from 1.8.4 to 1.8.5
- [Release notes](https://github.com/BLAKE3-team/BLAKE3/releases)
- [Commits](https://github.com/BLAKE3-team/BLAKE3/compare/1.8.4...1.8.5)

Updates `axum` from 0.8.8 to 0.8.9
- [Release notes](https://github.com/tokio-rs/axum/releases)
- [Changelog](https://github.com/tokio-rs/axum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/axum/compare/axum-v0.8.8...axum-v0.8.9)

Updates `clap` from 4.6.0 to 4.6.1
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.0...clap_complete-v4.6.1)

Updates `tray-icon` from 0.22.0 to 0.22.1
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.22...tray-icon-v0.22.1)

Updates `muda` from 0.17.2 to 0.18.0
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.17.2...muda-v0.18)

Updates `bitstream-io` from 4.9.0 to 4.10.0
- [Changelog](https://github.com/tuffy/bitstream-io/blob/master/CHANGES.md)
- [Commits](https://github.com/tuffy/bitstream-io/compare/v4.9.0...v4.10.0)

Updates `cc` from 1.2.60 to 1.2.61
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.60...cc-v1.2.61)

Updates `clap_derive` from 4.6.0 to 4.6.1
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/v4.6.0...v4.6.1)

Updates `crc-catalog` from 2.4.0 to 2.5.0
- [Commits](https://github.com/akhilles/crc-catalog/compare/2.4.0...2.5.0)

Updates `data-encoding` from 2.10.0 to 2.11.0
- [Commits](https://github.com/ia0/data-encoding/compare/v2.10.0...v2.11.0)

Updates `dbus` from 0.9.10 to 0.9.11
- [Commits](https://github.com/diwic/dbus-rs/compare/dbus-v0.9.10...dbus-v0.9.11)

Updates `embed-resource` from 3.0.8 to 3.0.9
- [Release notes](https://github.com/nabijaczleweli/rust-embed-resource/releases)
- [Commits](https://github.com/nabijaczleweli/rust-embed-resource/compare/v3.0.8...v3.0.9)

Updates `hybrid-array` from 0.4.10 to 0.4.11
- [Changelog](https://github.com/RustCrypto/hybrid-array/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RustCrypto/hybrid-array/compare/v0.4.10...v0.4.11)

Updates `hyper-rustls` from 0.27.7 to 0.27.9
- [Release notes](https://github.com/rustls/hyper-rustls/releases)
- [Commits](https://github.com/rustls/hyper-rustls/compare/v/0.27.7...v/0.27.9)

Updates `jiff` from 0.2.23 to 0.2.24
- [Release notes](https://github.com/BurntSushi/jiff/releases)
- [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.23...jiff-static-0.2.24)

Updates `jiff-static` from 0.2.23 to 0.2.24
- [Release notes](https://github.com/BurntSushi/jiff/releases)
- [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.23...jiff-static-0.2.24)

Updates `open` from 5.3.3 to 5.3.4
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.3...v5.3.4)

Updates `openssl` from 0.10.76 to 0.10.78
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.76...openssl-v0.10.78)

Updates `openssl-sys` from 0.9.112 to 0.9.114
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-sys-v0.9.112...openssl-sys-v0.9.114)

Updates `pkg-config` from 0.3.32 to 0.3.33
- [Changelog](https://github.com/rust-lang/pkg-config-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/pkg-config-rs/compare/0.3.32...0.3.33)

Updates `portable-atomic-util` from 0.2.6 to 0.2.7
- [Release notes](https://github.com/taiki-e/portable-atomic-util/releases)
- [Changelog](https://github.com/taiki-e/portable-atomic-util/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/portable-atomic-util/compare/v0.2.6...v0.2.7)

Updates `pxfm` from 0.1.28 to 0.1.29
- [Release notes](https://github.com/awxkee/pxfm/releases)
- [Commits](https://github.com/awxkee/pxfm/compare/0.1.28...0.1.29)

Updates `rayon` from 1.11.0 to 1.12.0
- [Changelog](https://github.com/rayon-rs/rayon/blob/main/RELEASES.md)
- [Commits](https://github.com/rayon-rs/rayon/compare/rayon-core-v1.11.0...rayon-core-v1.12.0)

Updates `rustls` from 0.23.37 to 0.23.39
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.39)

Updates `rustls-pki-types` from 1.14.0 to 1.14.1
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.14.0...v/1.14.1)

Updates `sqlite-wasm-rs` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/Spxg/sqlite-wasm-rs/releases)
- [Changelog](https://github.com/Spxg/sqlite-wasm-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Spxg/sqlite-wasm-rs/compare/0.5.2...0.5.3)

Updates `tungstenite` from 0.28.0 to 0.29.0
- [Changelog](https://github.com/snapview/tungstenite-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tungstenite-rs/compare/v0.28.0...v0.29.0)

Updates `typenum` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/paholg/typenum/releases)
- [Changelog](https://github.com/paholg/typenum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/paholg/typenum/compare/v1.19.0...v1.20.0)

Updates `wasip2` from 1.0.2+wasi-0.2.9 to 1.0.3+wasi-0.2.9
- [Commits](https://github.com/bytecodealliance/wasi-rs/compare/wasip2-1.0.2...wasip2-1.0.3)

Updates `web_atoms` from 0.2.3 to 0.2.4
- [Release notes](https://github.com/servo/html5ever/releases)
- [Commits](https://github.com/servo/html5ever/compare/v0.2.3...v0.2.4)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.186
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: blake3
  dependency-version: 1.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: axum
  dependency-version: 0.8.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: clap
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tray-icon
  dependency-version: 0.22.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: bitstream-io
  dependency-version: 4.10.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.61
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: clap_derive
  dependency-version: 4.6.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: crc-catalog
  dependency-version: 2.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: data-encoding
  dependency-version: 2.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: dbus
  dependency-version: 0.9.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hybrid-array
  dependency-version: 0.4.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hyper-rustls
  dependency-version: 0.27.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: jiff
  dependency-version: 0.2.24
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: jiff-static
  dependency-version: 0.2.24
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl
  dependency-version: 0.10.78
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl-sys
  dependency-version: 0.9.114
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pkg-config
  dependency-version: 0.3.33
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: portable-atomic-util
  dependency-version: 0.2.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pxfm
  dependency-version: 0.1.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rayon
  dependency-version: 1.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustls
  dependency-version: 0.23.39
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustls-pki-types
  dependency-version: 1.14.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sqlite-wasm-rs
  dependency-version: 0.5.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tungstenite
  dependency-version: 0.29.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: typenum
  dependency-version: 1.20.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: wasip2
  dependency-version: 1.0.3+wasi-0.2.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: web_atoms
  dependency-version: 0.2.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 14:48:26 +00:00
dependabot[bot] 07cda5119f ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) and [anomalyco/opencode](https://github.com/anomalyco/opencode).


Updates `pnpm/action-setup` from 6.0.1 to 6.0.3
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/078e9d416474b29c0c387560859308974f7e9c53...903f9c1a6ebcba6cf41d87230be49611ac97822e)

Updates `dependabot/fetch-metadata` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/ffa630c65fa7e0ecfa0625b5ceda64399aea1b36...25dd0e34f4fe68f24cc83900b1fe3fe149efef98)

Updates `anomalyco/opencode` from 1.4.11 to 1.14.24
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/a35b8a95c27d28e979a3826e1289d7ee87f40251...da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: dependabot/fetch-metadata
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.14.24
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 09:04:48 +00:00
54 changed files with 862 additions and 2799 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 #v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for minor and patch updates
+1 -1
View File
@@ -327,7 +327,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@a35b8a95c27d28e979a3826e1289d7ee87f40251 #v1.4.11
uses: anomalyco/opencode/github@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
with:
run_install: false
-1
View File
@@ -191,7 +191,6 @@
"osascript",
"oscpu",
"outpath",
"OVPN",
"pango",
"passout",
"patchelf",
+1 -1
View File
@@ -26,7 +26,7 @@ donutbrowser/
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
+54
View File
@@ -1,6 +1,60 @@
# Changelog
## v0.22.2 (2026-04-27)
### Refactoring
- cookie management
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
## v0.22.1 (2026-04-27)
### Bug Fixes
- link proper wayfern tos
### Refactoring
- vpn refresh and remove openvpn support
### Documentation
- update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
### Maintenance
- chore: version bump
- chore: linting
- chore: audit
- chore: update flake.nix for v0.22.0 [skip ci] (#307)
### Other
- deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
## v0.22.0 (2026-04-25)
### Refactoring
- auth and wayfern
- cdp gates cleanup
### Maintenance
- chore: tests
- chore:cargo audit
- chore: version bump
- chore: ignore .claude
- chore: update flake.nix for v0.21.2 [skip ci] (#298)
## v0.21.2 (2026-04-21)
### Bug Fixes
+6 -6
View File
@@ -34,7 +34,7 @@
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard and OpenVPN configs per profile
- **VPN support** — WireGuard configs per profile
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
- **Profile groups** — organize profiles and apply bulk settings
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
@@ -51,7 +51,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64.dmg) |
Or install via Homebrew:
@@ -61,15 +61,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut-0.21.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut-0.22.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut-0.22.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
-1
View File
@@ -4,7 +4,6 @@ extend-exclude = [
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
"src-tauri/build.rs",
"src-tauri/tests/fixtures/test.ovpn",
]
[default.extend-words]
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.21.2";
releaseVersion = "0.22.2";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_amd64.AppImage";
hash = "sha256-wHaH4CVKp7OkBQfohqA8+hU7jdYpvYj1DaqD1ow5yCg=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.AppImage";
hash = "sha256-90JcXImed7Ct+RYY41iG96ytFsGHAvfeNGlpjuGkeQI=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.2/Donut_0.21.2_aarch64.AppImage";
hash = "sha256-OX3NyTKBYxoH4j+rmfhlNHmiTaQbrKCiFxtqODF/NKM=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.AppImage";
hash = "sha256-46QFVm4OgbZgmkCiHcMJjv3O1sAGlelAEirhycmKlLo=";
}
else
null;
+5 -4
View File
@@ -2,14 +2,13 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.22.0",
"version": "0.22.3",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
"build": "next build",
"start": "next start",
"test": "pnpm test:rust:unit && pnpm test:openvpn-e2e && pnpm test:sync-e2e",
"test:openvpn-e2e": "node scripts/openvpn-test-harness.mjs",
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
@@ -92,7 +91,9 @@
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2"
}
},
"packageManager": "pnpm@10.33.0",
+31 -43
View File
@@ -7,6 +7,8 @@ settings:
overrides:
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12'
fast-xml-parser@<5.7.0: '>=5.7.2'
importers:
@@ -1425,6 +1427,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
'@nuxt/opencollective@0.4.1':
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
@@ -3782,11 +3787,11 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-builder@1.1.5:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
fast-xml-parser@5.5.8:
resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==}
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
hasBin: true
fb-watchman@2.0.2:
@@ -4690,8 +4695,8 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.2.1:
resolution: {integrity: sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-is-absolute@1.0.1:
@@ -4740,16 +4745,8 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
pretty-format@30.3.0:
@@ -5131,8 +5128,8 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strnum@2.2.2:
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
@@ -6075,7 +6072,7 @@ snapshots:
'@aws-sdk/xml-builder@3.972.16':
dependencies:
'@smithy/types': 4.13.1
fast-xml-parser: 5.5.8
fast-xml-parser: 5.7.2
tslib: 2.8.1
'@aws/lambda-invoke-store@0.2.4': {}
@@ -7027,6 +7024,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.0': {}
'@nuxt/opencollective@0.4.1':
dependencies:
consola: 3.4.2
@@ -8333,7 +8332,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
postcss: 8.5.8
postcss: 8.5.12
tailwindcss: 4.2.2
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -9425,15 +9424,16 @@ snapshots:
fast-uri@3.1.0: {}
fast-xml-builder@1.1.4:
fast-xml-builder@1.1.5:
dependencies:
path-expression-matcher: 1.2.1
path-expression-matcher: 1.5.0
fast-xml-parser@5.5.8:
fast-xml-parser@5.7.2:
dependencies:
fast-xml-builder: 1.1.4
path-expression-matcher: 1.2.1
strnum: 2.2.2
'@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5
path-expression-matcher: 1.5.0
strnum: 2.2.3
fb-watchman@2.0.2:
dependencies:
@@ -10360,7 +10360,7 @@ snapshots:
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.17
caniuse-lite: 1.0.30001787
postcss: 8.4.31
postcss: 8.5.12
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
@@ -10457,7 +10457,7 @@ snapshots:
path-exists@4.0.0: {}
path-expression-matcher@1.2.1: {}
path-expression-matcher@1.5.0: {}
path-is-absolute@1.0.1: {}
@@ -10491,19 +10491,7 @@ snapshots:
pluralize@8.0.0: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.9:
postcss@8.5.12:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -10992,7 +10980,7 @@ snapshots:
strip-json-comments@3.1.1: {}
strnum@2.2.2: {}
strnum@2.2.3: {}
strtok3@10.3.5:
dependencies:
@@ -11298,7 +11286,7 @@ snapshots:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.9
postcss: 8.5.12
rollup: 4.60.1
tinyglobby: 0.2.16
optionalDependencies:
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env node
/**
* OpenVPN E2E Test Harness
*
* This script:
* 1. Skips unless explicitly enabled via DONUTBROWSER_RUN_OPENVPN_E2E=1
* 2. Builds the Rust vpn_integration test binary without running it
* 3. Runs the OpenVPN e2e test binary under sudo
*
* Usage: DONUTBROWSER_RUN_OPENVPN_E2E=1 node scripts/openvpn-test-harness.mjs
*/
import { spawn } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const SRC_TAURI_DIR = path.join(ROOT_DIR, "src-tauri");
const TEST_NAME = "test_openvpn_traffic_flows_through_donut_proxy";
function log(message) {
console.log(`[openvpn-harness] ${message}`);
}
function error(message) {
console.error(`[openvpn-harness] ERROR: ${message}`);
}
function shouldRun() {
if (process.env.DONUTBROWSER_RUN_OPENVPN_E2E !== "1") {
log("Skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
return false;
}
if (process.platform !== "linux") {
log(`Skipping OpenVPN e2e test on unsupported platform: ${process.platform}`);
return false;
}
return true;
}
async function buildTestBinary() {
log("Building OpenVPN e2e test binary...");
return new Promise((resolve, reject) => {
let executablePath = "";
let stdoutBuffer = "";
const proc = spawn(
"cargo",
[
"test",
"--test",
"vpn_integration",
TEST_NAME,
"--no-run",
"--message-format=json",
],
{
cwd: SRC_TAURI_DIR,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
}
);
const parseBuffer = (flush = false) => {
const lines = stdoutBuffer.split("\n");
const completeLines = flush ? lines : lines.slice(0, -1);
stdoutBuffer = flush ? "" : lines.at(-1) ?? "";
for (const line of completeLines.filter(Boolean)) {
try {
const message = JSON.parse(line);
if (message.reason === "compiler-artifact" && message.executable) {
executablePath = message.executable;
}
} catch {
// Ignore non-JSON lines.
}
}
};
proc.stdout.on("data", (data) => {
stdoutBuffer += data.toString();
parseBuffer();
});
proc.stderr.on("data", (data) => {
process.stderr.write(data);
});
proc.on("error", (err) => {
reject(err);
});
proc.on("close", (code) => {
parseBuffer(true);
if (code !== 0) {
reject(new Error(`cargo test --no-run exited with code ${code}`));
return;
}
if (!executablePath) {
reject(new Error("Could not determine the vpn_integration test binary path"));
return;
}
resolve(path.isAbsolute(executablePath) ? executablePath : path.resolve(SRC_TAURI_DIR, executablePath));
});
});
}
async function runOpenVpnE2e(executablePath) {
log("Running OpenVPN e2e test under sudo...");
return new Promise((resolve, reject) => {
const proc = spawn(
"sudo",
[
"--preserve-env=CI,GITHUB_ACTIONS,VPN_TEST_OVPN_HOST,VPN_TEST_OVPN_PORT,DONUTBROWSER_RUN_OPENVPN_E2E",
executablePath,
TEST_NAME,
"--exact",
"--nocapture",
],
{
cwd: SRC_TAURI_DIR,
env: process.env,
stdio: "inherit",
}
);
proc.on("error", (err) => {
reject(err);
});
proc.on("close", (code) => {
resolve(code ?? 1);
});
});
}
async function main() {
if (!shouldRun()) {
process.exit(0);
}
try {
const executablePath = await buildTestBinary();
const exitCode = await runOpenVpnE2e(executablePath);
process.exit(exitCode);
} catch (err) {
error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
main();
+103 -113
View File
@@ -473,9 +473,9 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.8"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
@@ -500,7 +500,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.28.0",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -588,11 +588,11 @@ dependencies = [
[[package]]
name = "bitstream-io"
version = "4.9.0"
version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
dependencies = [
"core2",
"no_std_io2",
]
[[package]]
@@ -618,9 +618,9 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.4"
version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
@@ -946,9 +946,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.60"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1088,9 +1088,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1110,9 +1110,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -1267,15 +1267,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -1320,9 +1311,9 @@ dependencies = [
[[package]]
name = "crc-catalog"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
@@ -1514,9 +1505,9 @@ dependencies = [
[[package]]
name = "data-encoding"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
@@ -1526,13 +1517,13 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1789,7 +1780,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -1824,7 +1815,6 @@ dependencies = [
"maxminddb",
"mime_guess",
"msi-extract",
"muda",
"nix 0.31.2",
"objc2",
"objc2-app-kit",
@@ -1861,11 +1851,11 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",
"tokio-tungstenite",
"tokio-util",
"tower",
"tower-http",
"tray-icon 0.22.0",
"tray-icon 0.22.1",
"url",
"urlencoding",
"utoipa",
@@ -1951,14 +1941,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.8"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg 0.55.0",
]
@@ -3001,9 +2991,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"typenum",
]
@@ -3032,15 +3022,14 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.7"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
@@ -3100,7 +3089,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -3454,9 +3443,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.23"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
dependencies = [
"jiff-static",
"log",
@@ -3467,9 +3456,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.23"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
dependencies = [
"proc-macro2",
"quote",
@@ -3642,9 +3631,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
@@ -4086,6 +4075,15 @@ dependencies = [
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"
dependencies = [
"memchr",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -4461,9 +4459,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [
"dunce",
"is-wsl",
@@ -4473,9 +4471,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.76"
version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
@@ -4505,9 +4503,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [
"cc",
"libc",
@@ -4910,9 +4908,9 @@ dependencies = [
[[package]]
name = "pkg-config"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
@@ -5027,9 +5025,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
@@ -5205,9 +5203,9 @@ dependencies = [
[[package]]
name = "pxfm"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
@@ -5455,9 +5453,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -5853,9 +5851,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"once_cell",
"rustls-pki-types",
@@ -5866,9 +5864,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
@@ -6622,9 +6620,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
@@ -7521,9 +7519,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.51.1"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@@ -7596,18 +7594,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@@ -7619,7 +7605,7 @@ dependencies = [
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite 0.29.0",
"tungstenite",
]
[[package]]
@@ -7662,6 +7648,21 @@ dependencies = [
"winnow 0.7.15",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.1",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
@@ -7863,9 +7864,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.22.0"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553"
checksum = "7f9eb1da86bd0ab8931fad00650d2ba7473260c5bab06d6f24d04339edb88faa"
dependencies = [
"crossbeam-channel",
"dirs",
@@ -7897,23 +7898,6 @@ dependencies = [
"core_maths",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -7945,9 +7929,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uds_windows"
@@ -8205,9 +8189,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -8303,11 +8287,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
@@ -8316,7 +8300,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.51.0",
]
[[package]]
@@ -8433,9 +8417,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
@@ -9104,6 +9088,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.22.0"
version = "0.22.3"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -73,7 +73,7 @@ once_cell = "1"
urlencoding = "2.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
axum = { version = "0.8.8", features = ["ws"] }
axum = { version = "0.8.9", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.10.1"
@@ -111,7 +111,6 @@ smoltcp = { version = "0.13", default-features = false, features = ["std", "medi
# Daemon dependencies (tray icon)
tray-icon = "0.22"
muda = "0.17"
tao = "0.35"
image = "0.25"
dirs = "6"
+264 -2
View File
@@ -130,6 +130,39 @@ struct UpdateProxyRequest {
proxy_settings: Option<ProxySettings>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiVpnResponse {
id: String,
name: String,
/// Always "WireGuard"
vpn_type: String,
created_at: i64,
last_used: Option<i64>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportVpnRequest {
/// Raw WireGuard `.conf` file content
content: String,
/// Original filename
filename: String,
/// Optional display name; defaults to filename-based name
name: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateVpnRequest {
name: String,
/// Must be "WireGuard"
vpn_type: String,
config_data: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateVpnRequest {
name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct DownloadBrowserRequest {
browser: String,
@@ -191,6 +224,12 @@ struct OpenUrlRequest {
create_proxy,
update_proxy,
delete_proxy,
get_vpns,
get_vpn,
import_vpn,
create_vpn,
update_vpn,
delete_vpn,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
@@ -207,6 +246,10 @@ struct OpenUrlRequest {
ApiProxyResponse,
CreateProxyRequest,
UpdateProxyRequest,
ApiVpnResponse,
ImportVpnRequest,
CreateVpnRequest,
UpdateVpnRequest,
DownloadBrowserRequest,
DownloadBrowserResponse,
RunProfileResponse,
@@ -219,6 +262,7 @@ struct OpenUrlRequest {
(name = "groups", description = "Group management endpoints"),
(name = "tags", description = "Tag management endpoints"),
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
),
modifiers(&SecurityAddon),
@@ -311,6 +355,9 @@ impl ApiServer {
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(get_proxy, update_proxy, delete_proxy))
.routes(routes!(get_vpns, create_vpn))
.routes(routes!(import_vpn))
.routes(routes!(get_vpn, update_vpn, delete_vpn))
.routes(routes!(get_extensions))
.routes(routes!(delete_extension_api))
.routes(routes!(get_extension_groups))
@@ -1189,6 +1236,212 @@ async fn delete_proxy(
}
}
// API Handlers - VPNs
fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse {
ApiVpnResponse {
id: c.id.clone(),
name: c.name.clone(),
vpn_type: c.vpn_type.to_string(),
created_at: c.created_at,
last_used: c.last_used,
}
}
fn parse_vpn_type(s: &str) -> Option<crate::vpn::VpnType> {
match s.to_ascii_lowercase().as_str() {
"wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard),
_ => None,
}
}
#[utoipa::path(
get,
path = "/v1/vpns",
responses(
(status = 200, description = "List of all VPN configurations", body = Vec<ApiVpnResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn get_vpns(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiVpnResponse>>, StatusCode> {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let configs = storage
.list_configs()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(configs.iter().map(vpn_to_api_response).collect()))
}
#[utoipa::path(
get,
path = "/v1/vpns/{id}",
params(("id" = String, Path, description = "VPN configuration ID")),
responses(
(status = 200, description = "VPN configuration details", body = ApiVpnResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "VPN configuration not found"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn get_vpn(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let configs = storage
.list_configs()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
configs
.iter()
.find(|c| c.id == id)
.map(|c| Json(vpn_to_api_response(c)))
.ok_or(StatusCode::NOT_FOUND)
}
#[utoipa::path(
post,
path = "/v1/vpns/import",
request_body = ImportVpnRequest,
responses(
(status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse),
(status = 400, description = "Invalid or unrecognized VPN config"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn import_vpn(
State(_state): State<ApiServerState>,
Json(request): Json<ImportVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
let result = {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
storage.import_config(&request.content, &request.filename, request.name)
};
match result {
Ok(config) => {
let _ = events::emit("vpn-configs-changed", ());
Ok(Json(vpn_to_api_response(&config)))
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
#[utoipa::path(
post,
path = "/v1/vpns",
request_body = CreateVpnRequest,
responses(
(status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse),
(status = 400, description = "Invalid VPN config or unknown vpn_type"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn create_vpn(
State(_state): State<ApiServerState>,
Json(request): Json<CreateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?;
let result = {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
storage.create_config_manual(&request.name, vpn_type, &request.config_data)
};
match result {
Ok(config) => {
let _ = events::emit("vpn-configs-changed", ());
Ok(Json(vpn_to_api_response(&config)))
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
#[utoipa::path(
put,
path = "/v1/vpns/{id}",
params(("id" = String, Path, description = "VPN configuration ID")),
request_body = UpdateVpnRequest,
responses(
(status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "VPN configuration not found"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn update_vpn(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
Json(request): Json<UpdateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
let result = {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
storage.update_config_name(&id, &request.name)
};
match result {
Ok(config) => {
let _ = events::emit("vpn-configs-changed", ());
Ok(Json(vpn_to_api_response(&config)))
}
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
#[utoipa::path(
delete,
path = "/v1/vpns/{id}",
params(("id" = String, Path, description = "VPN configuration ID")),
responses(
(status = 204, description = "VPN configuration deleted successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "VPN configuration not found"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn delete_vpn(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await;
let result = {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
storage.delete_config(&id)
};
match result {
Ok(_) => {
let _ = events::emit("vpn-configs-changed", ());
Ok(StatusCode::NO_CONTENT)
}
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
// Extension API endpoints
#[utoipa::path(
@@ -1331,8 +1584,17 @@ async fn run_profile(
.await
.map_err(|_| StatusCode::CONFLICT)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
let remote_debugging_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let port = listener
.local_addr()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.port();
drop(listener);
port
};
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
+1 -1
View File
@@ -11,11 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use muda::MenuEvent;
use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::menu::MenuEvent;
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
+4 -17
View File
@@ -490,23 +490,10 @@ async fn main() {
let server =
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
if let Err(e) = server.run(id.clone()).await {
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
}
"openvpn" => {
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse OpenVPN config: {}", e);
process::exit(1);
}
};
let server =
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
if let Err(e) = server.run(id.clone()).await {
if let Err(e) = server
.run(id.clone(), config_path.map(std::path::PathBuf::from))
.await
{
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
+20 -42
View File
@@ -50,20 +50,6 @@ pub mod chrome_decrypt {
key
}
/// Get the encryption key for Chrome cookies.
///
/// Wayfern stores `os_crypt_key` as a plain file inside the profile's
/// user-data-dir on all platforms (see the wayfern patches for
/// `os_crypt_mac.mm` and `os_crypt_linux.cc`). The file contains a
/// base64-encoded 128-bit random value that is used as the PBKDF2
/// password — not as the raw AES key — matching Chromium's
/// `OSCryptImpl::DeriveKey` flow.
///
/// If the file is missing we return `None`. We must NEVER fall back to the
/// real macOS Keychain or any other system credential store. Wayfern
/// profiles are fully self-contained and reaching into another app's entry
/// would trigger the macOS "confidential information stored in …" prompt
/// and the "prevented from modifying other apps" warning.
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
let key_file = profile_data_path.join("os_crypt_key");
// Read as raw bytes and do NOT trim — Chromium's `ReadFileToString`
@@ -186,32 +172,34 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
/// Get the Chrome cookie encryption key for a Wayfern profile
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
chrome_decrypt::get_encryption_key(&profile_data_path)
}
fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf {
let default_dir = profile_data_path.join("Default");
#[cfg(target_os = "windows")]
{
default_dir.join("Network").join("Cookies")
}
#[cfg(not(target_os = "windows"))]
{
default_dir.join("Cookies")
}
}
/// Get the cookie database path for a profile (read-side: errors if missing).
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
match profile.browser.as_str() {
"wayfern" => {
let network_path = profile_data_path
.join("Default")
.join("Network")
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
let path = Self::wayfern_cookie_path(&profile_data_path);
if path.exists() {
Ok(path)
} else {
Err(format!(
"Cookie database not found at: {}",
network_path.display()
))
Err(format!("Cookie database not found at: {}", path.display()))
}
}
"camoufox" => {
@@ -241,21 +229,11 @@ impl CookieManager {
match profile.browser.as_str() {
"wayfern" => {
let network_path = profile_data_path
.join("Default")
.join("Network")
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
} else {
let dir = network_path.parent().unwrap();
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create Network dir: {e}"))?;
Self::create_empty_chrome_cookies_db(&network_path)?;
Ok(network_path)
let path = Self::wayfern_cookie_path(&profile_data_path);
if !path.exists() {
Self::create_empty_chrome_cookies_db(&path)?;
}
Ok(path)
}
"camoufox" => {
let path = profile_data_path.join("cookies.sqlite");
+1 -1
View File
@@ -1,5 +1,5 @@
use muda::{Menu, MenuItem};
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
-37
View File
@@ -769,42 +769,6 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
}
// VPN commands
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct VpnDependencyStatus {
is_available: bool,
requires_external_install: bool,
missing_binary: bool,
missing_windows_adapter: bool,
dependency_check_failed: bool,
}
#[tauri::command]
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
match vpn_type {
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
is_available: true,
requires_external_install: false,
missing_binary: false,
missing_windows_adapter: false,
dependency_check_failed: false,
}),
vpn::VpnType::OpenVPN => {
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
let is_available =
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
Ok(VpnDependencyStatus {
is_available,
requires_external_install: true,
missing_binary: !status.binary_found,
missing_windows_adapter: status.missing_windows_adapter,
dependency_check_failed: status.dependency_check_failed,
})
}
}
}
#[tauri::command]
async fn import_vpn_config(
content: String,
@@ -2075,7 +2039,6 @@ pub fn run() {
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
// VPN commands
get_vpn_dependency_status,
import_vpn_config,
list_vpn_configs,
get_vpn_config,
+3 -3
View File
@@ -848,17 +848,17 @@ impl McpServer {
// VPN management tools
McpTool {
name: "import_vpn".to_string(),
description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(),
description: "Import a WireGuard (.conf) configuration".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Raw VPN config file content"
"description": "Raw WireGuard config file content"
},
"filename": {
"type": "string",
"description": "Original filename (.conf or .ovpn) for type detection"
"description": "Original filename (.conf)"
},
"name": {
"type": "string",
+13 -197
View File
@@ -11,8 +11,6 @@ pub enum VpnError {
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}")]
@@ -31,14 +29,12 @@ pub enum VpnError {
#[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"),
}
}
}
@@ -72,19 +68,6 @@ pub struct WireGuardConfig {
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 {
@@ -110,26 +93,16 @@ pub struct VpnStatus {
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]")
if filename_lower.ends_with(".conf")
&& content.contains("[Interface]")
&& content.contains("[Peer]")
{
return Ok(VpnType::WireGuard);
}
if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) {
return Ok(VpnType::OpenVPN);
if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]")
{
return Ok(VpnType::WireGuard);
}
Err(VpnError::UnknownFormat)
@@ -254,75 +227,6 @@ fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> {
Ok(())
}
/// 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 let Some(port) = parts.get(2).and_then(|p| p.parse().ok()) {
remote_port = port;
}
if parts.len() >= 4 {
protocol = parts[3].to_string();
}
}
"proto" if parts.len() >= 2 => {
protocol = parts[1].to_string();
}
"port" => {
if let Some(port) = parts.get(1).and_then(|p| p.parse().ok()) {
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::*;
@@ -336,15 +240,6 @@ mod tests {
);
}
#[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";
@@ -354,21 +249,19 @@ mod tests {
);
}
#[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_reject_openvpn_content() {
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
assert!(detect_vpn_type(content, "test.ovpn").is_err());
assert!(detect_vpn_type(content, "config").is_err());
}
#[test]
fn test_parse_wireguard_config() {
let content = r#"
@@ -444,81 +337,4 @@ Endpoint = 1.2.3.4:51820
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"));
}
}
+5 -8
View File
@@ -1,23 +1,20 @@
//! VPN support module for WireGuard and OpenVPN configurations.
//! VPN support module for WireGuard configurations.
//!
//! This module provides:
//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files)
//! - WireGuard config parsing (`.conf` files)
//! - Encrypted storage for VPN configurations
//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management
//! - Tunnel management with userspace WireGuard (boringtun) routed through smoltcp
mod config;
mod openvpn;
pub mod openvpn_socks5;
pub mod socks5_server;
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,
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnError, VpnImportResult, VpnStatus,
VpnType, WireGuardConfig,
};
pub use openvpn::OpenVpnTunnel;
pub use storage::VpnStorage;
pub use tunnel::{TunnelManager, VpnTunnel};
pub use wireguard::WireGuardTunnel;
-349
View File
@@ -1,349 +0,0 @@
//! 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)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.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")),
}
}
}
-811
View File
@@ -1,811 +0,0 @@
use super::config::{OpenVpnConfig, VpnError};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream};
const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90;
enum SocksTarget {
Address(SocketAddr),
Domain(String, u16),
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct OpenVpnDependencyStatus {
pub binary_found: bool,
pub missing_windows_adapter: bool,
pub dependency_check_failed: bool,
}
pub struct OpenVpnSocks5Server {
config: OpenVpnConfig,
port: u16,
}
impl OpenVpnSocks5Server {
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
Self { config, port }
}
fn read_log_tail(path: &Path, lines: usize) -> String {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.rev()
.take(lines)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
for field in line.split(',') {
let trimmed = field.trim();
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
None
}
fn log_indicates_connected(log_content: &str) -> bool {
log_content.contains("Initialization Sequence Completed")
}
fn log_indicates_failure(log_content: &str) -> bool {
log_content.contains("AUTH_FAILED")
|| log_content.contains("Exiting due to fatal error")
|| log_content.contains("Fatal error")
|| log_content.contains("Options error")
|| log_content.contains("Exiting")
}
fn has_config_directive(config: &str, directive: &str) -> bool {
config.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with(';')
&& trimmed.starts_with(directive)
})
}
fn strip_config_directive(config: &str, directive: &str) -> String {
config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with(directive)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_runtime_config(&self) -> String {
let mut runtime_config = self.config.raw_config.clone();
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
}
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
}
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
}
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
}
#[cfg(windows)]
{
if Self::has_config_directive(&runtime_config, "dev-node") {
runtime_config = runtime_config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with("dev-node")
})
.collect::<Vec<_>>()
.join("\n");
}
if !Self::has_config_directive(&runtime_config, "disable-dco") {
runtime_config.push_str("\ndisable-dco\n");
}
if self.config.dev_type.starts_with("tun")
&& !Self::has_config_directive(&runtime_config, "windows-driver")
{
runtime_config.push_str("\nwindows-driver wintun\n");
}
}
runtime_config
}
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
return OpenVpnDependencyStatus {
binary_found: false,
missing_windows_adapter: false,
dependency_check_failed: false,
};
};
#[cfg(windows)]
{
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
Ok(has_adapter) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: !has_adapter,
dependency_check_failed: false,
},
Err(_) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: true,
},
}
}
#[cfg(not(windows))]
{
let _ = openvpn_bin;
OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: false,
}
}
}
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
return Err(VpnError::Connection(format!(
"Configured OpenVPN binary does not exist: {}",
path.display()
)));
}
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);
}
}
#[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)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("openvpn")
.creation_flags(CREATE_NO_WINDOW)
.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(),
))
}
fn openvpn_supports_management(openvpn_bin: &Path) -> bool {
let mut command = Command::new(openvpn_bin);
command.arg("--version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let Ok(output) = command.output() else {
return true;
};
let version_text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
!version_text.contains("enable_management=no")
}
#[cfg(windows)]
pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result<bool, VpnError> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new(openvpn_bin)
.arg("--show-adapters")
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(
text
.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
)
}
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
for line in log_content.lines() {
if let Some(ip) = Self::extract_vpn_ip(line) {
return Some(ip);
}
if let Some(position) = line.find("ifconfig ") {
let after = &line[position + "ifconfig ".len()..];
if let Some(ip_str) = after
.split_whitespace()
.next()
.or_else(|| after.split(',').next())
{
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
}
}
None
}
async fn wait_for_openvpn_ready_via_management(
child: &mut std::process::Child,
mgmt_port: u16,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
let mgmt_stream = loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
Ok(stream) => break stream,
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
}
};
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
let mut lines = BufReader::new(mgmt_reader).lines();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
interval.tick().await;
let mut vpn_ip = None;
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
tokio::select! {
line_result = lines.next_line() => {
match line_result {
Ok(Some(line)) => {
if let Some(ip) = Self::extract_vpn_ip(&line) {
vpn_ip = Some(ip);
}
if line.contains(",CONNECTED,") {
break;
}
if line.contains("AUTH_FAILED") {
return Err(VpnError::Connection(format!(
"OpenVPN authentication failed. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if line.contains(",EXITING,") || line.contains(">FATAL:") {
return Err(VpnError::Connection(format!(
"OpenVPN is exiting. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
}
Ok(None) => {
return Err(VpnError::Connection(format!(
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
Err(_) => {}
}
}
_ = interval.tick() => {
let _ = mgmt_writer.write_all(b"state\n").await;
let log_path = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
.await
.ok()
.and_then(Result::ok);
if let Some(content) = log_content {
if Self::log_indicates_connected(&content) {
break;
}
}
}
}
}
if vpn_ip.is_none() {
if let Ok(log_content) = std::fs::read_to_string(log_path) {
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
}
}
Ok(vpn_ip)
}
async fn wait_for_openvpn_ready_via_log(
child: &mut std::process::Child,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 40)
)));
}
let log_path_buf = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
if Self::log_indicates_connected(&log_content) {
return Ok(Self::extract_vpn_ip_from_log(&log_content));
}
if Self::log_indicates_failure(&log_content) {
return Err(VpnError::Connection(format!(
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
async fn connect_target(
target: SocksTarget,
vpn_bind_ip: Ipv4Addr,
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
let mut addresses = match target {
SocksTarget::Address(addr) => vec![addr],
SocksTarget::Domain(host, port) => {
let mut resolved = lookup_host((host.as_str(), port))
.await?
.collect::<Vec<_>>();
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
resolved
}
};
if addresses.is_empty() {
return Err("No addresses resolved for SOCKS5 target".into());
}
let mut last_error = None;
for address in addresses.drain(..) {
let socket = if address.is_ipv4() {
let socket = TcpSocket::new_v4()?;
if !vpn_bind_ip.is_unspecified() {
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
}
socket
} else {
TcpSocket::new_v6()?
};
match socket.connect(address).await {
Ok(stream) => return Ok((stream, address)),
Err(error) => last_error = Some(error),
}
}
Err(
last_error
.map(|error| error.into())
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
)
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let openvpn_bin = Self::find_openvpn_binary()?;
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
#[cfg(windows)]
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
return Err(VpnError::Connection(
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
));
}
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
}
let mgmt_port = if supports_management {
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
let port = mgmt_listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
.port();
drop(mgmt_listener);
Some(port)
} else {
log::info!(
"[vpn-worker] OpenVPN build does not support management; using log-based readiness"
);
None
};
let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id));
let log_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&openvpn_log_path)
.map_err(VpnError::Io)?;
let mut cmd = Command::new(&openvpn_bin);
cmd.arg("--config").arg(&config_path);
if let Some(mgmt_port) = mgmt_port {
cmd
.arg("--management")
.arg("127.0.0.1")
.arg(mgmt_port.to_string());
}
cmd
.arg("--verb")
.arg("3")
.stdout(
log_file
.try_clone()
.map(Stdio::from)
.map_err(VpnError::Io)?,
)
.stderr(Stdio::from(log_file));
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.arg("--disable-dco");
if self.config.dev_type.starts_with("tun") {
cmd.arg("--windows-driver").arg("wintun");
}
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd
.spawn()
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
match child.try_wait() {
Ok(Some(status)) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"OpenVPN exited immediately (status: {}). Last output:\n{}",
status,
Self::read_log_tail(&openvpn_log_path, 20)
)));
}
Ok(None) => {}
Err(e) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"Failed to check OpenVPN status: {e}"
)));
}
}
let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port {
Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await?
} else {
Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await?
}
.unwrap_or(Ipv4Addr::UNSPECIFIED);
let vpn_bind_ip = Arc::new(vpn_bind_ip);
let listener = TcpListener::bind(("127.0.0.1", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
let actual_port = listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
worker_config.local_port = Some(actual_port);
worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config);
}
log::info!(
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
actual_port
);
loop {
match listener.accept().await {
Ok((client, _)) => {
let bind_ip = vpn_bind_ip.clone();
tokio::spawn(async move {
let _ = Self::handle_socks5_client(client, bind_ip).await;
});
}
Err(error) => {
log::warn!("[vpn-worker] Accept error: {error}");
}
}
}
}
async fn handle_socks5_client(
mut client: TcpStream,
vpn_bind_ip: Arc<Ipv4Addr>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut greeting = [0u8; 2];
if let Err(error) = client.read_exact(&mut greeting).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read greeting header: {}", error);
}
return Ok(());
}
if greeting[0] != 0x05 {
return Ok(());
}
let mut methods = vec![0u8; greeting[1] as usize];
if let Err(error) = client.read_exact(&mut methods).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read methods list: {}", error);
}
return Ok(());
}
client.write_all(&[0x05, 0x00]).await?;
let mut request_header = [0u8; 4];
if let Err(error) = client.read_exact(&mut request_header).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read request header: {}", error);
}
return Ok(());
}
if request_header[0] != 0x05 {
return Ok(());
}
if request_header[1] != 0x01 {
let _ = client
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await;
return Ok(());
}
let target = match request_header[3] {
0x01 => {
let mut addr_port = [0u8; 6];
client.read_exact(&mut addr_port).await?;
SocksTarget::Address(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(
addr_port[0],
addr_port[1],
addr_port[2],
addr_port[3],
)),
u16::from_be_bytes([addr_port[4], addr_port[5]]),
))
}
0x03 => {
let mut len = [0u8; 1];
client.read_exact(&mut len).await?;
if len[0] == 0 {
return Ok(());
}
let mut domain = vec![0u8; len[0] as usize];
client.read_exact(&mut domain).await?;
let mut port = [0u8; 2];
client.read_exact(&mut port).await?;
SocksTarget::Domain(
String::from_utf8_lossy(&domain).to_string(),
u16::from_be_bytes(port),
)
}
0x04 => {
let mut addr_port = [0u8; 18];
client.read_exact(&mut addr_port).await?;
let mut octets = [0u8; 16];
octets.copy_from_slice(&addr_port[..16]);
SocksTarget::Address(SocketAddr::new(
IpAddr::V6(std::net::Ipv6Addr::from(octets)),
u16::from_be_bytes([addr_port[16], addr_port[17]]),
))
}
_ => {
let _ = client
.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await;
return Ok(());
}
};
match Self::connect_target(target, *vpn_bind_ip).await {
Ok((upstream, _address)) => {
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await?;
let (mut client_read, mut client_write) = client.into_split();
let (mut upstream_read, mut upstream_write) = upstream.into_split();
let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write);
let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write);
let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?;
}
Err(error) => {
log::debug!(
"[socks5] Failed to connect through OpenVPN tunnel: {}",
error
);
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_openvpn_binary_format() {
let result = OpenVpnSocks5Server::find_openvpn_binary();
match result {
Ok(path) => assert!(!path.as_os_str().is_empty()),
Err(e) => assert!(e.to_string().contains("not found")),
}
}
}
+53 -6
View File
@@ -52,8 +52,25 @@ impl WgDevice {
let mut dst = vec![0u8; ip_packet.len() + 256];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.encapsulate(&ip_packet, &mut dst);
if let TunnResult::WriteToNetwork(packet) = result {
let _ = self.udp_socket.send_to(packet, self.peer_addr);
match result {
TunnResult::WriteToNetwork(packet) => {
if let Err(e) = self.udp_socket.send_to(packet, self.peer_addr) {
log::error!("[wg] udp send_to failed: {e}");
}
}
TunnResult::Done => {
// boringtun has nothing to send right now (e.g. handshake not yet
// complete); silently drop. smoltcp will retransmit.
}
TunnResult::Err(e) => {
log::error!(
"[wg] encapsulate error for {}B IP packet: {e:?}",
ip_packet.len()
);
}
TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => {
log::error!("[wg] encapsulate returned unexpected WriteToTunnel — bug?");
}
}
}
}
@@ -313,7 +330,11 @@ impl WireGuardSocks5Server {
)))
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
pub async fn run(
self,
config_id: String,
config_path: Option<std::path::PathBuf>,
) -> Result<(), VpnError> {
let peer_addr = self.resolve_endpoint()?;
let mut tunn = self.create_tunnel()?;
@@ -371,11 +392,37 @@ impl WireGuardSocks5Server {
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
// Update config with actual port and local_url
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
// Update config with actual port and local_url. Prefer the explicit
// config path the worker was started with — see issue #287, where
// get_storage_dir() in the worker process resolved to a different
// directory than in the parent (Qubes/sandboxed Linux), causing the
// write-back to land in the wrong place and the parent to time out.
let updated = match &config_path {
Some(path) => crate::vpn_worker_storage::get_vpn_worker_config_from_path(path)
.or_else(|| crate::vpn_worker_storage::get_vpn_worker_config(&config_id)),
None => crate::vpn_worker_storage::get_vpn_worker_config(&config_id),
};
if let Some(mut wc) = updated {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
let result = match &config_path {
Some(path) => crate::vpn_worker_storage::save_vpn_worker_config_to_path(&wc, path)
.map_err(|e| e.to_string()),
None => crate::vpn_worker_storage::save_vpn_worker_config(&wc).map_err(|e| e.to_string()),
};
if let Err(e) = result {
log::error!(
"[vpn-worker] Failed to write back local_url to config: {} (path={:?})",
e,
config_path
);
}
} else {
log::error!(
"[vpn-worker] Could not load worker config for write-back (id={}, path={:?})",
config_id,
config_path
);
}
log::info!(
+13 -12
View File
@@ -161,7 +161,17 @@ impl VpnStorage {
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)
// Drop entries whose vpn_type isn't recognized by the current build (e.g.
// legacy "OpenVPN" entries after support was removed). Filtering at JSON
// level keeps the rest of the file deserializable instead of the whole
// load failing on a single unknown variant.
let mut value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))?;
if let Some(arr) = value.get_mut("configs").and_then(|v| v.as_array_mut()) {
arr.retain(|c| c.get("vpn_type").and_then(|t| t.as_str()) == Some("WireGuard"));
}
serde_json::from_value(value)
.map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))
}
@@ -328,14 +338,10 @@ impl VpnStorage {
vpn_type: VpnType,
config_data: &str,
) -> Result<VpnConfig, VpnError> {
// Validate the config by parsing it
match vpn_type {
VpnType::WireGuard => {
super::parse_wireguard_config(config_data)?;
}
VpnType::OpenVPN => {
super::parse_openvpn_config(config_data)?;
}
}
let id = Uuid::new_v4().to_string();
@@ -392,20 +398,15 @@ impl VpnStorage {
) -> 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");
let base = filename.trim_end_matches(".conf");
format!("{} ({})", base, vpn_type)
});
let sync_enabled = crate::sync::is_sync_configured();
@@ -491,7 +492,7 @@ mod tests {
let config2 = VpnConfig {
id: "id-2".to_string(),
name: "VPN 2".to_string(),
vpn_type: VpnType::OpenVPN,
vpn_type: VpnType::WireGuard,
config_data: "secret2".to_string(),
created_at: 2000,
last_used: Some(3000),
+4 -13
View File
@@ -9,7 +9,6 @@ use std::process::Stdio;
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
let Some(port) = config.local_port else {
@@ -44,13 +43,8 @@ fn read_worker_log(id: &str) -> String {
async fn wait_for_vpn_worker_ready(
id: &str,
vpn_type: &str,
) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
let startup_timeout = if vpn_type == "openvpn" {
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
} else {
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
};
let startup_timeout = tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS);
let startup_deadline = tokio::time::Instant::now() + startup_timeout;
tokio::time::sleep(tokio::time::Duration::from_millis(
@@ -124,7 +118,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
return Ok(existing);
}
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
return wait_for_vpn_worker_ready(&existing.id).await;
}
}
// Worker config exists but process is dead, clean up
@@ -141,10 +135,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
.map_err(|e| format!("Failed to load VPN config: {e}"))?
};
let vpn_type_str = match vpn_config.vpn_type {
crate::vpn::VpnType::WireGuard => "wireguard",
crate::vpn::VpnType::OpenVPN => "openvpn",
};
let vpn_type_str = "wireguard";
// Write decrypted config to a temp file
let config_file_path = std::env::temp_dir()
@@ -270,7 +261,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
drop(child);
}
wait_for_vpn_worker_ready(&id, vpn_type_str).await
wait_for_vpn_worker_ready(&id).await
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
+25 -2
View File
@@ -1,6 +1,7 @@
use crate::proxy_storage::get_storage_dir;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnWorkerConfig {
@@ -36,12 +37,34 @@ pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box<dyn st
fs::create_dir_all(&storage_dir)?;
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
save_vpn_worker_config_to_path(config, &file_path)
}
/// Write a worker config to a specific path. Used by detached worker
/// processes that already know their config file path (passed via
/// `--config-path`) and must write back to the same location regardless of
/// how `get_storage_dir()` resolves in the worker process — which can
/// differ from the parent on Linux distros that sandbox the GUI (Qubes,
/// flatpak, etc.) and is the cause of issue #287.
pub fn save_vpn_worker_config_to_path(
config: &VpnWorkerConfig,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(config)?;
fs::write(path, content)?;
Ok(())
}
/// Read a worker config from a specific path. Counterpart to
/// `save_vpn_worker_config_to_path`.
pub fn get_vpn_worker_config_from_path(path: &Path) -> Option<VpnWorkerConfig> {
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.22.0",
"version": "0.22.3",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
-39
View File
@@ -1,39 +0,0 @@
# 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>
+3 -206
View File
@@ -1,6 +1,6 @@
//! Test harness for VPN integration tests.
//!
//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests.
//! This module provides Docker-based test infrastructure for WireGuard tests.
//! In CI environments, it uses pre-configured service containers.
//! In local development, it spawns Docker containers on demand.
//!
@@ -13,10 +13,7 @@ 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";
const OVPN_VOLUME: &str = "donut-ovpn-test-data";
/// Check if running in CI environment
pub fn is_ci() -> bool {
@@ -27,10 +24,6 @@ fn has_external_wireguard_service() -> bool {
std::env::var("VPN_TEST_WG_HOST").is_ok()
}
fn has_external_openvpn_service() -> bool {
std::env::var("VPN_TEST_OVPN_HOST").is_ok()
}
/// Check if Docker is available
pub fn is_docker_available() -> bool {
Command::new("docker")
@@ -165,166 +158,10 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
Ok(config)
}
/// Start an OpenVPN test server and return client config
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
if has_external_openvpn_service() {
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());
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();
let _ = Command::new("docker")
.args(["volume", "rm", "-f", OVPN_VOLUME])
.output();
let create_volume = Command::new("docker")
.args(["volume", "create", OVPN_VOLUME])
.output()
.map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?;
if !create_volume.status.success() {
return Err(format!(
"Failed to create OpenVPN test volume: {}",
String::from_utf8_lossy(&create_volume.stderr)
));
}
let genconfig = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"ovpn_genconfig",
"-u",
"udp://127.0.0.1",
"-s",
"10.9.0.0/24",
])
.output()
.map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?;
if !genconfig.status.success() {
return Err(format!(
"OpenVPN config generation failed: {}",
String::from_utf8_lossy(&genconfig.stderr)
));
}
let init_pki = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"ovpn_initpki",
"nopass",
])
.output()
.map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?;
if !init_pki.status.success() {
return Err(format!(
"OpenVPN PKI initialization failed: {}",
String::from_utf8_lossy(&init_pki.stderr)
));
}
let build_client = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"easyrsa",
"build-client-full",
"donut-test-client",
"nopass",
])
.output()
.map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?;
if !build_client.status.success() {
return Err(format!(
"OpenVPN client certificate build failed: {}",
String::from_utf8_lossy(&build_client.stderr)
));
}
let start_server = Command::new("docker")
.args([
"run",
"-d",
"--name",
OVPN_CONTAINER,
"--cap-add=NET_ADMIN",
"-p",
"1194:1194/udp",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
OPENVPN_IMAGE,
])
.output()
.map_err(|e| format!("Failed to start OpenVPN container: {e}"))?;
if !start_server.status.success() {
return Err(format!(
"OpenVPN container start failed: {}",
String::from_utf8_lossy(&start_server.stderr)
));
}
sleep(Duration::from_secs(10)).await;
let client_config = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
OPENVPN_IMAGE,
"ovpn_getclient",
"donut-test-client",
])
.output()
.map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?;
if !client_config.status.success() {
return Err(format!(
"Failed to read OpenVPN client config: {}",
String::from_utf8_lossy(&client_config.stderr)
));
}
let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string();
Ok(OpenVpnTestConfig {
raw_config,
remote_host: "127.0.0.1".to_string(),
remote_port: 1194,
protocol: "udp".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();
let _ = Command::new("docker")
.args(["volume", "rm", "-f", OVPN_VOLUME])
.args(["rm", "-f", WG_CONTAINER])
.output();
}
@@ -343,14 +180,6 @@ pub struct WireGuardTestConfig {
pub server_tunnel_ip: 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();
@@ -436,7 +265,7 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
Ok(WireGuardTestConfig {
private_key,
address: "10.0.0.2/24".to_string(),
address: std::env::var("VPN_TEST_WG_ADDRESS").unwrap_or_else(|_| "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}"),
@@ -446,35 +275,3 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
.unwrap_or_else(|_| "10.0.0.1".to_string()),
})
}
/// Get OpenVPN config from CI environment
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") {
return Ok(OpenVpnTestConfig {
raw_config,
remote_host: host.to_string(),
remote_port: port.parse().unwrap_or(1194),
protocol: "udp".to_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(),
})
}
+55 -204
View File
@@ -8,8 +8,7 @@ mod test_harness;
use common::TestUtils;
use donutbrowser_lib::vpn::{
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
VpnStorage, VpnType, WireGuardConfig,
detect_vpn_type, parse_wireguard_config, VpnConfig, VpnStorage, VpnType, WireGuardConfig,
};
use serde_json::Value;
use serial_test::serial;
@@ -45,27 +44,6 @@ fn test_wireguard_config_import() {
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";
@@ -75,15 +53,6 @@ fn test_detect_vpn_type_wireguard_by_extension() {
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#"
@@ -101,20 +70,6 @@ Endpoint = 1.2.3.4:51820
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";
@@ -123,6 +78,13 @@ fn test_detect_vpn_type_unknown() {
assert!(result.is_err());
}
#[test]
fn test_reject_openvpn_content() {
let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194";
assert!(detect_vpn_type(content, "old.ovpn").is_err());
assert!(detect_vpn_type(content, "config.txt").is_err());
}
#[test]
fn test_wireguard_config_missing_private_key() {
let config = r#"
@@ -154,32 +116,6 @@ Address = 10.0.0.2/24
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
// ============================================================================
@@ -228,16 +164,11 @@ 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
},
vpn_type: VpnType::WireGuard,
config_data: "secret data".to_string(),
created_at: 1000 * i as i64,
last_used: None,
@@ -250,7 +181,6 @@ fn test_vpn_storage_list() {
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());
}
@@ -297,6 +227,52 @@ fn test_vpn_storage_import() {
assert!(!imported.id.is_empty());
}
/// Existing OpenVPN entries on disk should be silently dropped at load time
/// after support was removed. Stored configs are encrypted at rest, so we
/// build the on-disk JSON by hand instead of going through `save_config`.
#[test]
#[serial]
fn test_vpn_storage_drops_legacy_openvpn_entries() {
let temp_dir = tempfile::TempDir::new().unwrap();
let storage_path = temp_dir.path().join("vpn_configs.json");
std::fs::write(
&storage_path,
r#"{
"version": 1,
"configs": [
{
"id": "wg-keep",
"name": "Keep me",
"vpn_type": "WireGuard",
"encrypted_data": "",
"nonce": "",
"created_at": 1,
"last_used": null,
"sync_enabled": false,
"last_sync": null
},
{
"id": "ovpn-drop",
"name": "Drop me",
"vpn_type": "OpenVPN",
"encrypted_data": "",
"nonce": "",
"created_at": 2,
"last_used": null,
"sync_enabled": false,
"last_sync": null
}
]
}"#,
)
.unwrap();
let storage = create_test_storage(&temp_dir);
let configs = storage.list_configs().unwrap();
let ids: Vec<_> = configs.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, vec!["wg-keep"]);
}
// ============================================================================
// Helper Functions
// ============================================================================
@@ -309,13 +285,9 @@ fn create_test_storage(temp_dir: &tempfile::TempDir) -> VpnStorage {
// 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(),
@@ -337,30 +309,6 @@ async fn test_wireguard_tunnel_init() {
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() {
@@ -565,45 +513,6 @@ fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String
)
}
fn openvpn_client_available() -> bool {
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
return PathBuf::from(path).exists();
}
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
.arg("openvpn")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(windows)]
fn openvpn_adapter_available() -> bool {
let openvpn = std::process::Command::new("openvpn")
.arg("--show-adapters")
.output();
openvpn
.ok()
.map(|output| {
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
text
.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with("Available adapters"))
})
.unwrap_or(false)
}
#[cfg(not(windows))]
fn openvpn_adapter_available() -> bool {
true
}
async fn start_proxy_with_upstream(
binary_path: &PathBuf,
upstream_proxy: &str,
@@ -750,10 +659,6 @@ async fn run_proxy_feature_suite(
sleep(Duration::from_millis(500)).await;
// Test HTTP traffic through the tunnel to the internal HTTP server running
// inside the WireGuard container. This avoids depending on internet access
// from Docker (macOS Docker Desktop can't reliably NAT WireGuard tunnel
// traffic through to the internet).
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
let internal_host = format!("{}:8080", server_tunnel_ip);
let http_response =
@@ -790,7 +695,6 @@ async fn run_proxy_feature_suite(
stop_proxy(binary_path, &proxy.id).await?;
// DNS blocklist test: blocklist the tunnel server IP so it gets rejected
let blocklist_file = tempfile::NamedTempFile::new()?;
std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?;
let blocked_proxy = start_proxy_with_upstream(
@@ -896,56 +800,3 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
result
}
#[tokio::test]
#[serial]
async fn test_openvpn_traffic_flows_through_donut_proxy(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _env = TestEnvGuard::new()?;
cleanup_runtime().await;
if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E")
.ok()
.as_deref()
!= Some("1")
{
eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
return Ok(());
}
if !test_harness::is_docker_available() {
eprintln!("skipping OpenVPN e2e test because Docker is unavailable");
return Ok(());
}
if !openvpn_client_available() {
eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable");
return Ok(());
}
if !openvpn_adapter_available() {
eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available");
return Ok(());
}
let binary_path = ensure_donut_proxy_binary().await?;
let ovpn_config = match test_harness::start_openvpn_server().await {
Ok(config) => config,
Err(error) => {
eprintln!("skipping OpenVPN e2e test: {error}");
return Ok(());
}
};
let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config);
{
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
storage.save_config(&vpn_config)?;
}
// OpenVPN test uses the server's tunnel IP for internal-only traffic.
// The OpenVPN server's subnet is 10.9.0.0/24, server at 10.9.0.1.
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id, "10.9.0.1").await;
cleanup_runtime().await;
result
}
+4 -8
View File
@@ -1068,7 +1068,7 @@ export function CreateProfileDialog({
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
? `WG${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
@@ -1154,9 +1154,7 @@ export function CreateProfileDialog({
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
WG
</Badge>
{vpn.name}
</CommandItem>
@@ -1417,7 +1415,7 @@ export function CreateProfileDialog({
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
? `WG${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
@@ -1503,9 +1501,7 @@ export function CreateProfileDialog({
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
WG
</Badge>
{vpn.name}
</CommandItem>
+2 -6
View File
@@ -2219,11 +2219,7 @@ export function ProfilesDataTable({
: effectiveProxy
? effectiveProxy.name
: "Not Selected";
const vpnBadge = effectiveVpn
? effectiveVpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"
: null;
const vpnBadge = effectiveVpn ? "WG" : null;
const tooltipText = hasAssignment ? displayName : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
@@ -2385,7 +2381,7 @@ export function ProfilesDataTable({
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
WG
</Badge>
{vpn.name}
</CommandItem>
+2 -4
View File
@@ -179,9 +179,7 @@ export function ProxyAssignmentDialog({
if (selectionType === "none") return "None";
if (selectionType === "vpn") {
const vpn = vpnConfigs.find((v) => v.id === selectedId);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "None";
return vpn ? `WG — ${vpn.name}` : "None";
}
const proxy = storedProxies.find(
(p) => p.id === selectedId,
@@ -264,7 +262,7 @@ export function ProxyAssignmentDialog({
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
WG
</Badge>
{vpn.name}
</CommandItem>
+1 -5
View File
@@ -670,11 +670,7 @@ export function ProxyManagementDialog({
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
<Badge variant="outline">WG</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
+1 -1
View File
@@ -111,7 +111,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium overflow-y-scroll",
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
+162 -381
View File
@@ -3,10 +3,8 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
@@ -19,15 +17,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { VpnConfig, VpnType } from "@/types";
import type { VpnConfig } from "@/types";
interface VpnFormDialogProps {
isOpen: boolean;
@@ -48,19 +38,6 @@ interface WireGuardFormData {
presharedKey: string;
}
interface OpenVpnFormData {
name: string;
rawConfig: string;
}
interface VpnDependencyStatus {
isAvailable: boolean;
requiresExternalInstall: boolean;
missingBinary: boolean;
missingWindowsAdapter: boolean;
dependencyCheckFailed: boolean;
}
const defaultWireGuardForm: WireGuardFormData = {
name: "",
privateKey: "",
@@ -74,11 +51,6 @@ const defaultWireGuardForm: WireGuardFormData = {
presharedKey: "",
};
const defaultOpenVpnForm: OpenVpnFormData = {
name: "",
rawConfig: "",
};
function buildWireGuardConfig(form: WireGuardFormData): string {
const lines: string[] = ["[Interface]"];
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
@@ -102,63 +74,24 @@ export function VpnFormDialog({
onClose,
editingVpn,
}: VpnFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
const [wireGuardForm, setWireGuardForm] =
useState<WireGuardFormData>(defaultWireGuardForm);
const [openVpnForm, setOpenVpnForm] =
useState<OpenVpnFormData>(defaultOpenVpnForm);
const [vpnDependencyStatus, setVpnDependencyStatus] =
useState<VpnDependencyStatus | null>(null);
const resetForms = useCallback(() => {
setVpnType("WireGuard");
setWireGuardForm(defaultWireGuardForm);
setOpenVpnForm(defaultOpenVpnForm);
}, []);
useEffect(() => {
if (isOpen) {
if (editingVpn) {
setVpnType(editingVpn.vpn_type);
if (editingVpn.vpn_type === "WireGuard") {
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
} else {
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
}
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
} else {
resetForms();
}
}
}, [isOpen, editingVpn, resetForms]);
useEffect(() => {
if (!isOpen) {
setVpnDependencyStatus(null);
return;
}
let cancelled = false;
void invoke<VpnDependencyStatus>("get_vpn_dependency_status", { vpnType })
.then((status) => {
if (!cancelled) {
setVpnDependencyStatus(status);
}
})
.catch((error) => {
console.error("Failed to load VPN dependency status:", error);
if (!cancelled) {
setVpnDependencyStatus(null);
}
});
return () => {
cancelled = true;
};
}, [isOpen, vpnType]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
onClose();
@@ -167,10 +100,7 @@ export function VpnFormDialog({
const handleSubmit = useCallback(async () => {
if (editingVpn) {
const name =
vpnType === "WireGuard"
? wireGuardForm.name.trim()
: openVpnForm.name.trim();
const name = wireGuardForm.name.trim();
if (!name) {
toast.error("VPN name is required");
@@ -196,80 +126,49 @@ export function VpnFormDialog({
return;
}
if (vpnType === "WireGuard") {
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
wireGuardForm;
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
wireGuardForm;
if (!name.trim()) {
toast.error("VPN name is required");
return;
}
if (!privateKey.trim()) {
toast.error("Private key is required");
return;
}
if (!address.trim()) {
toast.error("Address is required");
return;
}
if (!peerPublicKey.trim()) {
toast.error("Peer public key is required");
return;
}
if (!peerEndpoint.trim()) {
toast.error("Peer endpoint is required");
return;
}
setIsSubmitting(true);
try {
const configData = buildWireGuardConfig(wireGuardForm);
await invoke("create_vpn_config_manual", {
name: name.trim(),
vpnType: "WireGuard",
configData,
});
await emit("vpn-configs-changed");
toast.success("WireGuard VPN created successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
} else {
const { name, rawConfig } = openVpnForm;
if (!name.trim()) {
toast.error("VPN name is required");
return;
}
if (!rawConfig.trim()) {
toast.error("OpenVPN config content is required");
return;
}
setIsSubmitting(true);
try {
await invoke("create_vpn_config_manual", {
name: name.trim(),
vpnType: "OpenVPN",
configData: rawConfig,
});
await emit("vpn-configs-changed");
toast.success("OpenVPN configuration created successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
if (!name.trim()) {
toast.error("VPN name is required");
return;
}
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
if (!privateKey.trim()) {
toast.error("Private key is required");
return;
}
if (!address.trim()) {
toast.error("Address is required");
return;
}
if (!peerPublicKey.trim()) {
toast.error("Peer public key is required");
return;
}
if (!peerEndpoint.trim()) {
toast.error("Peer endpoint is required");
return;
}
setIsSubmitting(true);
try {
const configData = buildWireGuardConfig(wireGuardForm);
await invoke("create_vpn_config_manual", {
name: name.trim(),
vpnType: "WireGuard",
configData,
});
await emit("vpn-configs-changed");
toast.success("WireGuard VPN created successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
}, [editingVpn, wireGuardForm, onClose]);
const updateWireGuard = useCallback(
(field: keyof WireGuardFormData, value: string) => {
@@ -278,54 +177,10 @@ export function VpnFormDialog({
[],
);
const updateOpenVpn = useCallback(
(field: keyof OpenVpnFormData, value: string) => {
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
},
[],
);
const dialogTitle = editingVpn
? "Edit VPN"
: vpnType === "WireGuard"
? "Create WireGuard VPN"
: "Create OpenVPN Configuration";
const dialogTitle = editingVpn ? "Edit VPN" : "Create WireGuard VPN";
const dialogDescription = editingVpn
? "Update the name of your VPN configuration."
: vpnType === "WireGuard"
? "Enter your WireGuard interface and peer details."
: "Paste your .ovpn configuration file content.";
let dependencyWarningTitle: string | null = null;
let dependencyWarningDescription: string | null = null;
if (
vpnType === "OpenVPN" &&
vpnDependencyStatus?.requiresExternalInstall &&
!vpnDependencyStatus.isAvailable
) {
if (vpnDependencyStatus.missingBinary) {
dependencyWarningTitle = t("vpnForm.dependencies.openVpnMissingTitle");
dependencyWarningDescription = t(
"vpnForm.dependencies.openVpnMissingDescription",
);
} else if (vpnDependencyStatus.missingWindowsAdapter) {
dependencyWarningTitle = t(
"vpnForm.dependencies.openVpnAdapterMissingTitle",
);
dependencyWarningDescription = t(
"vpnForm.dependencies.openVpnAdapterMissingDescription",
);
} else if (vpnDependencyStatus.dependencyCheckFailed) {
dependencyWarningTitle = t(
"vpnForm.dependencies.openVpnCheckFailedTitle",
);
dependencyWarningDescription = t(
"vpnForm.dependencies.openVpnCheckFailedDescription",
);
}
}
: "Enter your WireGuard interface and peer details.";
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -337,221 +192,147 @@ export function VpnFormDialog({
<ScrollArea className="max-h-[60vh] pr-4">
<div className="grid gap-4 py-2">
{dependencyWarningTitle && dependencyWarningDescription && (
<Alert className="border-warning/50 bg-warning/10">
<AlertTitle className="text-warning">
{dependencyWarningTitle}
</AlertTitle>
<AlertDescription className="text-warning">
{dependencyWarningDescription}
</AlertDescription>
</Alert>
)}
<div className="grid gap-2">
<Label htmlFor="wg-name">Name</Label>
<Input
id="wg-name"
value={wireGuardForm.name}
onChange={(e) => {
updateWireGuard("name", e.target.value);
}}
placeholder="e.g. Home WireGuard"
disabled={isSubmitting}
/>
</div>
{!editingVpn && (
<div className="grid gap-2">
<Label>VPN Type</Label>
<Select
value={vpnType}
onValueChange={(value) => {
setVpnType(value as VpnType);
}}
disabled={isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select VPN type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="WireGuard">WireGuard</SelectItem>
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
</SelectContent>
</Select>
</div>
)}
{vpnType === "WireGuard" && (
<>
<div className="grid gap-2">
<Label htmlFor="wg-name">Name</Label>
<Label htmlFor="wg-private-key">Private Key</Label>
<Input
id="wg-name"
value={wireGuardForm.name}
id="wg-private-key"
value={wireGuardForm.privateKey}
onChange={(e) => {
updateWireGuard("name", e.target.value);
updateWireGuard("privateKey", e.target.value);
}}
placeholder="e.g. Home WireGuard"
placeholder="Base64-encoded private key"
disabled={isSubmitting}
/>
</div>
{!editingVpn && (
<>
<div className="grid gap-2">
<Label htmlFor="wg-private-key">Private Key</Label>
<Input
id="wg-private-key"
value={wireGuardForm.privateKey}
onChange={(e) => {
updateWireGuard("privateKey", e.target.value);
}}
placeholder="Base64-encoded private key"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-address">Address</Label>
<Input
id="wg-address"
value={wireGuardForm.address}
onChange={(e) => {
updateWireGuard("address", e.target.value);
}}
placeholder="e.g. 10.0.0.2/24"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="wg-dns">DNS (optional)</Label>
<Input
id="wg-dns"
value={wireGuardForm.dns}
onChange={(e) => {
updateWireGuard("dns", e.target.value);
}}
placeholder="e.g. 1.1.1.1"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-mtu">MTU (optional)</Label>
<Input
id="wg-mtu"
type="number"
value={wireGuardForm.mtu}
onChange={(e) => {
updateWireGuard("mtu", e.target.value);
}}
placeholder="e.g. 1420"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-public-key">
Peer Public Key
</Label>
<Input
id="wg-peer-public-key"
value={wireGuardForm.peerPublicKey}
onChange={(e) => {
updateWireGuard("peerPublicKey", e.target.value);
}}
placeholder="Base64-encoded peer public key"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
<Input
id="wg-peer-endpoint"
value={wireGuardForm.peerEndpoint}
onChange={(e) => {
updateWireGuard("peerEndpoint", e.target.value);
}}
placeholder="e.g. vpn.example.com:51820"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
<Input
id="wg-allowed-ips"
value={wireGuardForm.allowedIps}
onChange={(e) => {
updateWireGuard("allowedIps", e.target.value);
}}
placeholder="e.g. 0.0.0.0/0, ::/0"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="wg-keepalive">
Persistent Keepalive (optional)
</Label>
<Input
id="wg-keepalive"
type="number"
value={wireGuardForm.persistentKeepalive}
onChange={(e) => {
updateWireGuard(
"persistentKeepalive",
e.target.value,
);
}}
placeholder="e.g. 25"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-preshared-key">
Preshared Key (optional)
</Label>
<Input
id="wg-preshared-key"
value={wireGuardForm.presharedKey}
onChange={(e) => {
updateWireGuard("presharedKey", e.target.value);
}}
placeholder="Base64-encoded preshared key"
disabled={isSubmitting}
/>
</div>
</div>
</>
)}
</>
)}
{vpnType === "OpenVPN" && (
<>
<div className="grid gap-2">
<Label htmlFor="ovpn-name">Name</Label>
<Label htmlFor="wg-address">Address</Label>
<Input
id="ovpn-name"
value={openVpnForm.name}
id="wg-address"
value={wireGuardForm.address}
onChange={(e) => {
updateOpenVpn("name", e.target.value);
updateWireGuard("address", e.target.value);
}}
placeholder="e.g. Work OpenVPN"
placeholder="e.g. 10.0.0.2/24"
disabled={isSubmitting}
/>
</div>
{!editingVpn && (
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="ovpn-config">Raw Config</Label>
<Textarea
id="ovpn-config"
value={openVpnForm.rawConfig}
<Label htmlFor="wg-dns">DNS (optional)</Label>
<Input
id="wg-dns"
value={wireGuardForm.dns}
onChange={(e) => {
updateOpenVpn("rawConfig", e.target.value);
updateWireGuard("dns", e.target.value);
}}
placeholder="Paste your .ovpn file content here..."
className="min-h-[200px] font-mono text-xs"
placeholder="e.g. 1.1.1.1"
disabled={isSubmitting}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="wg-mtu">MTU (optional)</Label>
<Input
id="wg-mtu"
type="number"
value={wireGuardForm.mtu}
onChange={(e) => {
updateWireGuard("mtu", e.target.value);
}}
placeholder="e.g. 1420"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-public-key">Peer Public Key</Label>
<Input
id="wg-peer-public-key"
value={wireGuardForm.peerPublicKey}
onChange={(e) => {
updateWireGuard("peerPublicKey", e.target.value);
}}
placeholder="Base64-encoded peer public key"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
<Input
id="wg-peer-endpoint"
value={wireGuardForm.peerEndpoint}
onChange={(e) => {
updateWireGuard("peerEndpoint", e.target.value);
}}
placeholder="e.g. vpn.example.com:51820"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
<Input
id="wg-allowed-ips"
value={wireGuardForm.allowedIps}
onChange={(e) => {
updateWireGuard("allowedIps", e.target.value);
}}
placeholder="e.g. 0.0.0.0/0, ::/0"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="wg-keepalive">
Persistent Keepalive (optional)
</Label>
<Input
id="wg-keepalive"
type="number"
value={wireGuardForm.persistentKeepalive}
onChange={(e) => {
updateWireGuard("persistentKeepalive", e.target.value);
}}
placeholder="e.g. 25"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-preshared-key">
Preshared Key (optional)
</Label>
<Input
id="wg-preshared-key"
value={wireGuardForm.presharedKey}
onChange={(e) => {
updateWireGuard("presharedKey", e.target.value);
}}
placeholder="Base64-encoded preshared key"
disabled={isSubmitting}
/>
</div>
</div>
</>
)}
</div>
+6 -23
View File
@@ -52,17 +52,6 @@ const detectVpnType = (
endpoint: endpointMatch ? endpointMatch[1] : null,
};
}
if (
lowerFilename.endsWith(".ovpn") ||
(content.includes("remote ") &&
(content.includes("client") || content.includes("dev tun")))
) {
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
const endpoint = remoteMatch
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
: null;
return { isVpn: true, type: "OpenVPN", endpoint };
}
return { isVpn: false, type: null, endpoint: null };
};
@@ -105,7 +94,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
endpoint: detection.endpoint,
});
const baseName = filename
.replace(/\.(conf|ovpn)$/i, "")
.replace(/\.conf$/i, "")
.replace(/_/g, " ")
.replace(/-/g, " ");
setVpnName(baseName || `${detection.type} VPN`);
@@ -132,13 +121,11 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const validFile = files.find(
(f) => f.name.endsWith(".conf") || f.name.endsWith(".ovpn"),
);
const validFile = files.find((f) => f.name.endsWith(".conf"));
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .conf or .ovpn file");
toast.error("Please drop a WireGuard .conf file");
}
},
[handleFileRead],
@@ -200,7 +187,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<DialogTitle>Import VPN Config</DialogTitle>
<DialogDescription>
{step === "dropzone" &&
"Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration file"}
"Import a WireGuard (.conf) configuration file"}
{step === "vpn-preview" && "Review the VPN configuration to import"}
{step === "vpn-result" && "VPN import completed"}
</DialogDescription>
@@ -230,16 +217,12 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Drop a VPN config file here or click to browse
<br />
<span className="text-xs">
(.conf for WireGuard, .ovpn for OpenVPN)
</span>
Drop a WireGuard .conf file here or click to browse
</p>
<input
id="vpn-file-input"
type="file"
accept=".conf,.ovpn"
accept=".conf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
+2 -2
View File
@@ -68,12 +68,12 @@ export function WayfernTermsDialog({
Please review the Terms and Conditions at:
</p>
<a
href="https://wayfern.com/terms-and-conditions"
href="https://wayfern.com/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm font-medium block"
>
https://wayfern.com/terms-and-conditions
https://wayfern.com/tos
</a>
<p className="text-sm text-muted-foreground">
By clicking "I Accept", you agree to be bound by these terms.
-10
View File
@@ -812,16 +812,6 @@
"button": "Clone"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN is not installed",
"openVpnMissingDescription": "You can save this configuration, but Donut Browser cannot connect it until OpenVPN is installed on this device.",
"openVpnAdapterMissingTitle": "OpenVPN adapter is missing",
"openVpnAdapterMissingDescription": "OpenVPN is installed, but no TAP/Wintun/ovpn-dco adapter was found. Repair or reinstall OpenVPN before connecting on Windows.",
"openVpnCheckFailedTitle": "OpenVPN install could not be verified",
"openVpnCheckFailedDescription": "Donut Browser could not inspect the local OpenVPN installation. Repair or reinstall OpenVPN before connecting on Windows."
}
},
"extensions": {
"title": "Extensions",
"description": "Manage browser extensions and extension groups for your profiles.",
-10
View File
@@ -812,16 +812,6 @@
"button": "Clonar"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN no está instalado",
"openVpnMissingDescription": "Puedes guardar esta configuración, pero Donut Browser no podrá conectarse hasta que OpenVPN esté instalado en este dispositivo.",
"openVpnAdapterMissingTitle": "Falta el adaptador de OpenVPN",
"openVpnAdapterMissingDescription": "OpenVPN está instalado, pero no se encontró ningún adaptador TAP/Wintun/ovpn-dco. Repara o reinstala OpenVPN antes de conectarte en Windows.",
"openVpnCheckFailedTitle": "No se pudo verificar la instalación de OpenVPN",
"openVpnCheckFailedDescription": "Donut Browser no pudo inspeccionar la instalación local de OpenVPN. Repara o reinstala OpenVPN antes de conectarte en Windows."
}
},
"extensions": {
"title": "Extensiones",
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
-10
View File
@@ -812,16 +812,6 @@
"button": "Cloner"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN n'est pas installé",
"openVpnMissingDescription": "Vous pouvez enregistrer cette configuration, mais Donut Browser ne pourra pas s'y connecter tant qu'OpenVPN n'est pas installé sur cet appareil.",
"openVpnAdapterMissingTitle": "L'adaptateur OpenVPN est manquant",
"openVpnAdapterMissingDescription": "OpenVPN est installé, mais aucun adaptateur TAP/Wintun/ovpn-dco n'a été trouvé. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows.",
"openVpnCheckFailedTitle": "L'installation d'OpenVPN n'a pas pu être vérifiée",
"openVpnCheckFailedDescription": "Donut Browser n'a pas pu inspecter l'installation locale d'OpenVPN. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows."
}
},
"extensions": {
"title": "Extensions",
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
-10
View File
@@ -812,16 +812,6 @@
"button": "複製"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN がインストールされていません",
"openVpnMissingDescription": "この設定は保存できますが、このデバイスに OpenVPN がインストールされるまで Donut Browser では接続できません。",
"openVpnAdapterMissingTitle": "OpenVPN アダプターが見つかりません",
"openVpnAdapterMissingDescription": "OpenVPN はインストールされていますが、TAP/Wintun/ovpn-dco アダプターが見つかりませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。",
"openVpnCheckFailedTitle": "OpenVPN のインストールを確認できませんでした",
"openVpnCheckFailedDescription": "Donut Browser はローカルの OpenVPN インストールを確認できませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。"
}
},
"extensions": {
"title": "拡張機能",
"description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。",
-10
View File
@@ -812,16 +812,6 @@
"button": "Clonar"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN não está instalado",
"openVpnMissingDescription": "Você pode salvar esta configuração, mas o Donut Browser não poderá se conectar até que o OpenVPN esteja instalado neste dispositivo.",
"openVpnAdapterMissingTitle": "O adaptador do OpenVPN está ausente",
"openVpnAdapterMissingDescription": "O OpenVPN está instalado, mas nenhum adaptador TAP/Wintun/ovpn-dco foi encontrado. Repare ou reinstale o OpenVPN antes de se conectar no Windows.",
"openVpnCheckFailedTitle": "Não foi possível verificar a instalação do OpenVPN",
"openVpnCheckFailedDescription": "O Donut Browser não conseguiu inspecionar a instalação local do OpenVPN. Repare ou reinstale o OpenVPN antes de se conectar no Windows."
}
},
"extensions": {
"title": "Extensões",
"description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.",
-10
View File
@@ -812,16 +812,6 @@
"button": "Клонировать"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "OpenVPN не установлен",
"openVpnMissingDescription": "Вы можете сохранить эту конфигурацию, но Donut Browser не сможет подключиться, пока OpenVPN не будет установлен на этом устройстве.",
"openVpnAdapterMissingTitle": "Отсутствует адаптер OpenVPN",
"openVpnAdapterMissingDescription": "OpenVPN установлен, но адаптер TAP/Wintun/ovpn-dco не найден. Восстановите или переустановите OpenVPN перед подключением в Windows.",
"openVpnCheckFailedTitle": "Не удалось проверить установку OpenVPN",
"openVpnCheckFailedDescription": "Donut Browser не смог проверить локальную установку OpenVPN. Восстановите или переустановите OpenVPN перед подключением в Windows."
}
},
"extensions": {
"title": "Расширения",
"description": "Управляйте расширениями браузера и группами расширений для ваших профилей.",
-10
View File
@@ -812,16 +812,6 @@
"button": "克隆"
}
},
"vpnForm": {
"dependencies": {
"openVpnMissingTitle": "未安装 OpenVPN",
"openVpnMissingDescription": "你现在可以保存这个配置,但在此设备上安装 OpenVPN 之前,Donut Browser 无法连接它。",
"openVpnAdapterMissingTitle": "缺少 OpenVPN 适配器",
"openVpnAdapterMissingDescription": "已安装 OpenVPN,但未找到 TAP/Wintun/ovpn-dco 适配器。在 Windows 上连接前,请修复或重新安装 OpenVPN。",
"openVpnCheckFailedTitle": "无法验证 OpenVPN 安装",
"openVpnCheckFailedDescription": "Donut Browser 无法检查本机 OpenVPN 安装。在 Windows 上连接前,请修复或重新安装 OpenVPN。"
}
},
"extensions": {
"title": "扩展程序",
"description": "管理配置文件的浏览器扩展程序和扩展程序组。",
+1 -1
View File
@@ -667,7 +667,7 @@ export type ProxyParseResult =
| { status: "invalid"; line: string; reason: string };
// VPN types
export type VpnType = "WireGuard" | "OpenVPN";
export type VpnType = "WireGuard";
export interface VpnConfig {
id: string;