mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-05-20 07:26:58 -04:00
Compare commits
13 Commits
8de183a53d
...
15e798c1e7
Author | SHA1 | Date | |
---|---|---|---|
|
15e798c1e7 | ||
|
1af21432d4 | ||
|
c07e232ec2 | ||
|
d975f981cc | ||
|
c74ce52c2d | ||
|
da3dc10569 | ||
|
75db9e162f | ||
|
89eb42ceac | ||
|
54d3bb8a92 | ||
|
a1b49fde47 | ||
|
f42f0d248e | ||
|
ec0bff1e36 | ||
|
77fda0f527 |
@ -13,3 +13,9 @@ indent_size = 4
|
|||||||
|
|
||||||
[*.{c,h}]
|
[*.{c,h}]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{js,jsx,ts,tsx}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -1,13 +1,21 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"authcookie",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
|
"clickaway",
|
||||||
|
"clientgpversion",
|
||||||
"clientos",
|
"clientos",
|
||||||
|
"gpcommon",
|
||||||
|
"gpservice",
|
||||||
|
"Immer",
|
||||||
"jnlp",
|
"jnlp",
|
||||||
|
"oneshot",
|
||||||
"openconnect",
|
"openconnect",
|
||||||
"prelogin",
|
"prelogin",
|
||||||
"prelogon",
|
"prelogon",
|
||||||
"prelogonuserauthcookie",
|
"prelogonuserauthcookie",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
"unlisten",
|
||||||
"userauthcookie",
|
"userauthcookie",
|
||||||
"vpninfo"
|
"vpninfo"
|
||||||
],
|
],
|
||||||
|
124
Cargo.lock
generated
124
Cargo.lock
generated
@ -51,14 +51,19 @@ checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800"
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"gpcommon",
|
||||||
"log",
|
"log",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"veil",
|
||||||
|
"webkit2gtk",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -112,6 +117,17 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atty"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi 0.1.19",
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -347,6 +363,17 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colored"
|
||||||
|
version = "1.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59"
|
||||||
|
dependencies = [
|
||||||
|
"atty",
|
||||||
|
"lazy_static",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.6"
|
version = "4.6.6"
|
||||||
@ -357,23 +384,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"cc",
|
|
||||||
"data-encoding",
|
|
||||||
"log",
|
|
||||||
"ring",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -552,6 +562,12 @@ version = "2.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23d8666cb01533c39dde32bcbab8e227b4ed6679b2c925eba05feabea39508fb"
|
checksum = "23d8666cb01533c39dde32bcbab8e227b4ed6679b2c925eba05feabea39508fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d7439c3735f405729d52c3fbbe4de140eaf938a1fe47d227c27f8254d4302a5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.17"
|
version = "0.99.17"
|
||||||
@ -700,6 +716,7 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee"
|
checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"colored",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1093,16 +1110,33 @@ dependencies = [
|
|||||||
name = "gpclient"
|
name = "gpclient"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"gpcommon",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gpcommon"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"cc",
|
||||||
|
"data-encoding",
|
||||||
|
"log",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpservice"
|
name = "gpservice"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"gpcommon",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@ -1183,6 +1217,15 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@ -1775,6 +1818,16 @@ version = "1.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66"
|
checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open"
|
||||||
|
version = "3.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8"
|
||||||
|
dependencies = [
|
||||||
|
"pathdiff",
|
||||||
|
"windows-sys 0.42.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.45"
|
version = "0.10.45"
|
||||||
@ -1874,6 +1927,12 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathdiff"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@ -2811,6 +2870,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"attohttpc",
|
"attohttpc",
|
||||||
"cocoa",
|
"cocoa",
|
||||||
|
"data-url",
|
||||||
"dirs-next",
|
"dirs-next",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
@ -2824,9 +2884,11 @@ dependencies = [
|
|||||||
"ignore",
|
"ignore",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"open",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"regex",
|
||||||
"semver 1.0.16",
|
"semver 1.0.16",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2880,6 +2942,7 @@ dependencies = [
|
|||||||
"png",
|
"png",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
"regex",
|
||||||
"semver 1.0.16",
|
"semver 1.0.16",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -3354,6 +3417,27 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "veil"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb8e42ca783c4c7ced40f4f0e11f13d545791c002a2e7adbe6d740b853087880"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"veil-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "veil-macros"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1eef6b882bba6052c6ab6a751f8f765794de7f957cbf0c5a97e7d2b46a3ae60d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.16",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.0.11"
|
version = "0.0.11"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
|
|
||||||
members = [
|
members = [
|
||||||
"common",
|
"gpcommon",
|
||||||
"gpclient",
|
"gpclient",
|
||||||
"gpservice",
|
"gpservice",
|
||||||
"gpgui/src-tauri"
|
"gpgui/src-tauri"
|
||||||
|
@ -6,5 +6,5 @@ edition = "2021"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
gpcommon = { path = "../gpcommon" }
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use common::{Client, SOCKET_PATH};
|
use gpcommon::{Client, SOCKET_PATH};
|
||||||
use tokio::{io::AsyncReadExt, net::UnixStream, sync::mpsc};
|
use tokio::{io::AsyncReadExt, net::UnixStream, sync::mpsc};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
0
common/.gitignore → gpcommon/.gitignore
vendored
0
common/.gitignore → gpcommon/.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "gpcommon"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
use crate::cmd::{Connect, Disconnect, Status};
|
use crate::cmd::{Connect, Disconnect, GetStatus};
|
||||||
use crate::reader::Reader;
|
use crate::reader::Reader;
|
||||||
use crate::request::CommandPayload;
|
use crate::request::CommandPayload;
|
||||||
use crate::response::ResponseData;
|
use crate::response::ResponseData;
|
||||||
@ -7,7 +7,7 @@ use crate::RequestPool;
|
|||||||
use crate::Response;
|
use crate::Response;
|
||||||
use crate::SOCKET_PATH;
|
use crate::SOCKET_PATH;
|
||||||
use crate::{Request, VpnStatus};
|
use crate::{Request, VpnStatus};
|
||||||
use log::{info, warn, debug};
|
use log::{debug, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -17,17 +17,24 @@ use tokio::sync::{mpsc, Mutex, RwLock};
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum ServerEvent {
|
enum ServiceEvent {
|
||||||
|
Online,
|
||||||
Response(Response),
|
Response(Response),
|
||||||
ServerDisconnected,
|
Offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Response> for ServerEvent {
|
impl From<Response> for ServiceEvent {
|
||||||
fn from(response: Response) -> Self {
|
fn from(response: Response) -> Self {
|
||||||
Self::Response(response)
|
Self::Response(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ClientStatus {
|
||||||
|
Vpn(VpnStatus),
|
||||||
|
Service(bool),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
// pool of requests that are waiting for responses
|
// pool of requests that are waiting for responses
|
||||||
@ -37,10 +44,10 @@ pub struct Client {
|
|||||||
// rx for receiving requests from the channel
|
// rx for receiving requests from the channel
|
||||||
request_rx: Arc<Mutex<mpsc::Receiver<Request>>>,
|
request_rx: Arc<Mutex<mpsc::Receiver<Request>>>,
|
||||||
// tx for sending responses to the channel
|
// tx for sending responses to the channel
|
||||||
server_event_tx: mpsc::Sender<ServerEvent>,
|
service_event_tx: mpsc::Sender<ServiceEvent>,
|
||||||
// rx for receiving responses from the channel
|
// rx for receiving responses from the channel
|
||||||
server_event_rx: Arc<Mutex<mpsc::Receiver<ServerEvent>>>,
|
service_event_rx: Arc<Mutex<mpsc::Receiver<ServiceEvent>>>,
|
||||||
is_healthy: Arc<RwLock<bool>>,
|
is_online: Arc<RwLock<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@ -71,34 +78,41 @@ impl From<&str> for ServerApiError {
|
|||||||
impl Default for Client {
|
impl Default for Client {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let (request_tx, request_rx) = mpsc::channel::<Request>(32);
|
let (request_tx, request_rx) = mpsc::channel::<Request>(32);
|
||||||
let (server_event_tx, server_event_rx) = mpsc::channel::<ServerEvent>(32);
|
let (service_event_tx, server_event_rx) = mpsc::channel::<ServiceEvent>(32);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
request_pool: Default::default(),
|
request_pool: Default::default(),
|
||||||
request_tx,
|
request_tx,
|
||||||
request_rx: Arc::new(Mutex::new(request_rx)),
|
request_rx: Arc::new(Mutex::new(request_rx)),
|
||||||
server_event_tx,
|
service_event_tx,
|
||||||
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
|
service_event_rx: Arc::new(Mutex::new(server_event_rx)),
|
||||||
is_healthy: Default::default(),
|
is_online: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
pub fn subscribe_status(&self, callback: impl Fn(VpnStatus) + Send + Sync + 'static) {
|
pub async fn is_online(&self) -> bool {
|
||||||
let server_event_rx = self.server_event_rx.clone();
|
*self.is_online.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe_status(&self, callback: impl Fn(ClientStatus) + Send + Sync + 'static) {
|
||||||
|
let service_event_rx = self.service_event_rx.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let mut server_event_rx = server_event_rx.lock().await;
|
let mut server_event_rx = service_event_rx.lock().await;
|
||||||
if let Some(server_event) = server_event_rx.recv().await {
|
if let Some(server_event) = server_event_rx.recv().await {
|
||||||
match server_event {
|
match server_event {
|
||||||
ServerEvent::ServerDisconnected => {
|
ServiceEvent::Online => {
|
||||||
callback(VpnStatus::Disconnected);
|
callback(ClientStatus::Service(true));
|
||||||
}
|
}
|
||||||
ServerEvent::Response(response) => {
|
ServiceEvent::Offline => {
|
||||||
|
callback(ClientStatus::Service(false));
|
||||||
|
}
|
||||||
|
ServiceEvent::Response(response) => {
|
||||||
if let ResponseData::Status(vpn_status) = response.data() {
|
if let ResponseData::Status(vpn_status) = response.data() {
|
||||||
callback(vpn_status);
|
callback(ClientStatus::Vpn(vpn_status));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -134,7 +148,7 @@ impl Client {
|
|||||||
let read_handle = tokio::spawn(handle_read(
|
let read_handle = tokio::spawn(handle_read(
|
||||||
read_stream,
|
read_stream,
|
||||||
self.request_pool.clone(),
|
self.request_pool.clone(),
|
||||||
self.server_event_tx.clone(),
|
self.service_event_tx.clone(),
|
||||||
cancel_token.clone(),
|
cancel_token.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -144,13 +158,16 @@ impl Client {
|
|||||||
cancel_token,
|
cancel_token,
|
||||||
));
|
));
|
||||||
|
|
||||||
*self.is_healthy.write().await = true;
|
*self.is_online.write().await = true;
|
||||||
info!("Connected to the background service");
|
info!("Connected to the background service");
|
||||||
|
if let Err(err) = self.service_event_tx.send(ServiceEvent::Online).await {
|
||||||
|
warn!("Error sending online event to the channel: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = tokio::join!(read_handle, write_handle);
|
let _ = tokio::join!(read_handle, write_handle);
|
||||||
*self.is_healthy.write().await = false;
|
*self.is_online.write().await = false;
|
||||||
|
|
||||||
// TODO connection was lost, cleanup the request pool and notify the UI
|
// TODO connection was lost, cleanup the request pool
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -159,7 +176,7 @@ impl Client {
|
|||||||
&self,
|
&self,
|
||||||
payload: CommandPayload,
|
payload: CommandPayload,
|
||||||
) -> Result<T, ServerApiError> {
|
) -> Result<T, ServerApiError> {
|
||||||
if !*self.is_healthy.read().await {
|
if !*self.is_online.read().await {
|
||||||
return Err("Background service is not running".into());
|
return Err("Background service is not running".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,18 +186,19 @@ impl Client {
|
|||||||
return Err(format!("Error sending request to the channel: {}", err).into());
|
return Err(format!("Error sending request to the channel: {}", err).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(response) = response_rx.await {
|
response_rx
|
||||||
if response.success() {
|
.await
|
||||||
match response.data().try_into() {
|
.map_err(|_| "Error receiving response from the channel".into())
|
||||||
Ok(it) => Ok(it),
|
.and_then(|response| {
|
||||||
Err(_) => Err("Error parsing response data".into()),
|
if response.success() {
|
||||||
|
response
|
||||||
|
.data()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "Error parsing response data".into())
|
||||||
|
} else {
|
||||||
|
Err(response.message().into())
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
Err(response.message().into())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Error receiving response from the channel".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(&self, server: String, cookie: String) -> Result<(), ServerApiError> {
|
pub async fn connect(&self, server: String, cookie: String) -> Result<(), ServerApiError> {
|
||||||
@ -192,14 +210,14 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn status(&self) -> Result<VpnStatus, ServerApiError> {
|
pub async fn status(&self) -> Result<VpnStatus, ServerApiError> {
|
||||||
self.send_command(Status.into()).await
|
self.send_command(GetStatus.into()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_read(
|
async fn handle_read(
|
||||||
read_stream: ReadHalf<UnixStream>,
|
read_stream: ReadHalf<UnixStream>,
|
||||||
request_pool: Arc<RequestPool>,
|
request_pool: Arc<RequestPool>,
|
||||||
server_event_tx: mpsc::Sender<ServerEvent>,
|
service_event_tx: mpsc::Sender<ServiceEvent>,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let mut reader: Reader = read_stream.into();
|
let mut reader: Reader = read_stream.into();
|
||||||
@ -211,7 +229,7 @@ async fn handle_read(
|
|||||||
match response.request_id() {
|
match response.request_id() {
|
||||||
Some(id) => request_pool.complete_request(id, response).await,
|
Some(id) => request_pool.complete_request(id, response).await,
|
||||||
None => {
|
None => {
|
||||||
if let Err(err) = server_event_tx.send(response.into()).await {
|
if let Err(err) = service_event_tx.send(response.into()).await {
|
||||||
warn!("Error sending response to output channel: {}", err);
|
warn!("Error sending response to output channel: {}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,7 +238,7 @@ async fn handle_read(
|
|||||||
}
|
}
|
||||||
Err(err) if err.kind() == io::ErrorKind::ConnectionAborted => {
|
Err(err) if err.kind() == io::ErrorKind::ConnectionAborted => {
|
||||||
warn!("Disconnected from the background service");
|
warn!("Disconnected from the background service");
|
||||||
if let Err(err) = server_event_tx.send(ServerEvent::ServerDisconnected).await {
|
if let Err(err) = service_event_tx.send(ServiceEvent::Offline).await {
|
||||||
warn!(
|
warn!(
|
||||||
"Error sending server disconnected event to channel: {}",
|
"Error sending server disconnected event to channel: {}",
|
||||||
err
|
err
|
@ -12,7 +12,7 @@ mod status;
|
|||||||
|
|
||||||
pub use connect::Connect;
|
pub use connect::Connect;
|
||||||
pub use disconnect::Disconnect;
|
pub use disconnect::Disconnect;
|
||||||
pub use status::Status;
|
pub use status::GetStatus;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct CommandContext {
|
pub(crate) struct CommandContext {
|
@ -4,10 +4,10 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Status;
|
pub struct GetStatus;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Command for Status {
|
impl Command for GetStatus {
|
||||||
async fn handle(&self, context: CommandContext) -> Result<ResponseData, CommandError> {
|
async fn handle(&self, context: CommandContext) -> Result<ResponseData, CommandError> {
|
||||||
let status = context.server_context.vpn().status().await;
|
let status = context.server_context.vpn().status().await;
|
||||||
|
|
@ -23,30 +23,31 @@ async fn handle_read(
|
|||||||
let mut authenticated: Option<bool> = None;
|
let mut authenticated: Option<bool> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read::<Request>().await {
|
match reader.read_multiple::<Request>().await {
|
||||||
Ok(request) => {
|
Ok(requests) => {
|
||||||
if authenticated.is_none() {
|
if authenticated.is_none() {
|
||||||
authenticated = Some(authenticate(peer_pid));
|
authenticated = Some(authenticate(peer_pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authenticated.unwrap_or(false) {
|
if !authenticated.unwrap_or(false) {
|
||||||
warn!("Client not authenticated, closing connection");
|
warn!("Client not authenticated, closing connection");
|
||||||
cancel_token.cancel();
|
cancel_token.cancel();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Received client request: {:?}", request);
|
for request in requests {
|
||||||
|
debug!("Received client request: {:?}", request);
|
||||||
|
|
||||||
let command = request.command();
|
let command = request.command();
|
||||||
let context = server_context.clone().into();
|
let context = server_context.clone().into();
|
||||||
|
|
||||||
let mut response = match command.handle(context).await {
|
let mut response = match command.handle(context).await {
|
||||||
Ok(data) => Response::from(data),
|
Ok(data) => Response::from(data),
|
||||||
Err(err) => Response::from(err.to_string()),
|
Err(err) => Response::from(err.to_string()),
|
||||||
};
|
};
|
||||||
response.set_request_id(request.id());
|
response.set_request_id(request.id());
|
||||||
|
|
||||||
let _ = response_tx.send(response).await;
|
let _ = response_tx.send(response).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::ConnectionAborted => {
|
Err(err) if err.kind() == io::ErrorKind::ConnectionAborted => {
|
@ -30,6 +30,7 @@ pub(crate) use writer::Writer;
|
|||||||
|
|
||||||
pub use client::Client;
|
pub use client::Client;
|
||||||
pub use client::ServerApiError;
|
pub use client::ServerApiError;
|
||||||
|
pub use client::ClientStatus;
|
||||||
pub use vpn::VpnStatus;
|
pub use vpn::VpnStatus;
|
||||||
|
|
||||||
pub fn sha256_digest<P: AsRef<Path>>(file_path: P) -> Result<String, std::io::Error> {
|
pub fn sha256_digest<P: AsRef<Path>>(file_path: P) -> Result<String, std::io::Error> {
|
@ -13,22 +13,6 @@ impl From<ReadHalf<UnixStream>> for Reader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Reader {
|
impl Reader {
|
||||||
pub async fn read<T: for<'a> Deserialize<'a>>(&mut self) -> Result<T, io::Error> {
|
|
||||||
let mut buffer = [0; 2048];
|
|
||||||
|
|
||||||
match self.stream.read(&mut buffer).await {
|
|
||||||
Ok(0) => Err(io::Error::new(
|
|
||||||
io::ErrorKind::ConnectionAborted,
|
|
||||||
"Peer disconnected",
|
|
||||||
)),
|
|
||||||
Ok(bytes_read) => {
|
|
||||||
let data = serde_json::from_slice::<T>(&buffer[..bytes_read])?;
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_multiple<T: for<'a> Deserialize<'a>>(&mut self) -> Result<Vec<T>, io::Error> {
|
pub async fn read_multiple<T: for<'a> Deserialize<'a>>(&mut self) -> Result<Vec<T>, io::Error> {
|
||||||
let mut buffer = [0; 2048];
|
let mut buffer = [0; 2048];
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
use crate::cmd::{Command, Connect, Disconnect, Status};
|
use crate::cmd::{Command, Connect, Disconnect, GetStatus};
|
||||||
use crate::Response;
|
use crate::Response;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -21,7 +21,7 @@ impl Request {
|
|||||||
|
|
||||||
pub fn command(&self) -> Box<dyn Command> {
|
pub fn command(&self) -> Box<dyn Command> {
|
||||||
match &self.payload {
|
match &self.payload {
|
||||||
CommandPayload::Status(status) => Box::new(status.clone()),
|
CommandPayload::GetStatus(status) => Box::new(status.clone()),
|
||||||
CommandPayload::Connect(connect) => Box::new(connect.clone()),
|
CommandPayload::Connect(connect) => Box::new(connect.clone()),
|
||||||
CommandPayload::Disconnect(disconnect) => Box::new(disconnect.clone()),
|
CommandPayload::Disconnect(disconnect) => Box::new(disconnect.clone()),
|
||||||
}
|
}
|
||||||
@ -30,14 +30,14 @@ impl Request {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub(crate) enum CommandPayload {
|
pub(crate) enum CommandPayload {
|
||||||
Status(Status),
|
GetStatus(GetStatus),
|
||||||
Connect(Connect),
|
Connect(Connect),
|
||||||
Disconnect(Disconnect),
|
Disconnect(Disconnect),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Status> for CommandPayload {
|
impl From<GetStatus> for CommandPayload {
|
||||||
fn from(status: Status) -> Self {
|
fn from(status: GetStatus) -> Self {
|
||||||
Self::Status(status)
|
Self::GetStatus(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
use log::{warn, info, debug};
|
use log::{debug, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::ffi::{c_void, CString};
|
use std::ffi::{c_void, CString};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -91,7 +91,7 @@ impl Vpn {
|
|||||||
*self.vpn_options.lock().await = Some(VpnOptions {
|
*self.vpn_options.lock().await = Some(VpnOptions {
|
||||||
server: VpnOptions::to_cstr(server),
|
server: VpnOptions::to_cstr(server),
|
||||||
cookie: VpnOptions::to_cstr(cookie),
|
cookie: VpnOptions::to_cstr(cookie),
|
||||||
script: VpnOptions::to_cstr("/usr/share/vpnc-scripts/vpnc-script")
|
script: VpnOptions::to_cstr("/usr/share/vpnc-scripts/vpnc-script"),
|
||||||
});
|
});
|
||||||
|
|
||||||
let vpn_options = self.vpn_options.clone();
|
let vpn_options = self.vpn_options.clone();
|
||||||
@ -128,17 +128,20 @@ impl Vpn {
|
|||||||
|
|
||||||
pub async fn disconnect(&self) {
|
pub async fn disconnect(&self) {
|
||||||
if self.status().await == VpnStatus::Disconnected {
|
if self.status().await == VpnStatus::Disconnected {
|
||||||
info!("VPN already disconnected, skipping disconnect");
|
info!("VPN is not connected, nothing to do");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Disconnecting VPN...");
|
info!("Disconnecting VPN...");
|
||||||
|
self.status_holder
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set(VpnStatus::Disconnecting);
|
||||||
unsafe { ffi::disconnect() };
|
unsafe { ffi::disconnect() };
|
||||||
|
|
||||||
let mut status_rx = self.status_rx().await;
|
let mut status_rx = self.status_rx().await;
|
||||||
debug!("Waiting for the VPN to disconnect...");
|
debug!("Waiting for the VPN to disconnect...");
|
||||||
|
|
||||||
|
|
||||||
while status_rx.changed().await.is_ok() {
|
while status_rx.changed().await.is_ok() {
|
||||||
if *status_rx.borrow() == VpnStatus::Disconnected {
|
if *status_rx.borrow() == VpnStatus::Disconnected {
|
||||||
info!("VPN disconnected");
|
info!("VPN disconnected");
|
@ -45,7 +45,7 @@ static void setup_tun_handler(void *_vpninfo)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize VPN connection */
|
/* Initialize VPN connection */
|
||||||
int vpn_connect(const Options *options)
|
int vpn_connect(const vpn_options *options)
|
||||||
{
|
{
|
||||||
struct openconnect_info *vpninfo;
|
struct openconnect_info *vpninfo;
|
||||||
struct utsname utsbuf;
|
struct utsname utsbuf;
|
@ -3,15 +3,15 @@
|
|||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
#include <openconnect.h>
|
#include <openconnect.h>
|
||||||
|
|
||||||
typedef struct Options
|
typedef struct vpn_options
|
||||||
{
|
{
|
||||||
const char *server;
|
const char *server;
|
||||||
const char *cookie;
|
const char *cookie;
|
||||||
const char *script;
|
const char *script;
|
||||||
void *user_data;
|
void *user_data;
|
||||||
} Options;
|
} vpn_options;
|
||||||
|
|
||||||
int vpn_connect(const Options *options);
|
int vpn_connect(const vpn_options *options);
|
||||||
void vpn_disconnect();
|
void vpn_disconnect();
|
||||||
|
|
||||||
extern void on_vpn_connected(int cmd_pipe_fd, void *user_data);
|
extern void on_vpn_connected(int cmd_pipe_fd, void *user_data);
|
@ -15,13 +15,18 @@
|
|||||||
"@mui/lab": "5.0.0-alpha.125",
|
"@mui/lab": "5.0.0-alpha.125",
|
||||||
"@mui/material": "^5.11.11",
|
"@mui/material": "^5.11.11",
|
||||||
"@tauri-apps/api": "^1.3.0",
|
"@tauri-apps/api": "^1.3.0",
|
||||||
|
"immer": "^10.0.2",
|
||||||
|
"jotai": "^2.1.1",
|
||||||
|
"jotai-immer": "^0.2.0",
|
||||||
|
"jotai-optics": "^0.3.0",
|
||||||
|
"optics-ts": "^2.4.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-spinners": "^0.13.8",
|
"react-spinners": "^0.13.8",
|
||||||
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log"
|
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^1.2.3",
|
"@tauri-apps/cli": "^1.3.1",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||||
|
125
gpgui/pnpm-lock.yaml
generated
125
gpgui/pnpm-lock.yaml
generated
@ -1,4 +1,8 @@
|
|||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.1'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/react':
|
'@emotion/react':
|
||||||
@ -19,6 +23,21 @@ dependencies:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0
|
version: 1.3.0
|
||||||
|
immer:
|
||||||
|
specifier: ^10.0.2
|
||||||
|
version: 10.0.2
|
||||||
|
jotai:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1(react@18.2.0)
|
||||||
|
jotai-immer:
|
||||||
|
specifier: ^0.2.0
|
||||||
|
version: 0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0)
|
||||||
|
jotai-optics:
|
||||||
|
specifier: ^0.3.0
|
||||||
|
version: 0.3.0(jotai@2.1.1)(optics-ts@2.4.0)
|
||||||
|
optics-ts:
|
||||||
|
specifier: ^2.4.0
|
||||||
|
version: 2.4.0
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
@ -34,8 +53,8 @@ dependencies:
|
|||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.3.1
|
||||||
version: 1.2.3
|
version: 1.3.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.0.27
|
specifier: ^18.0.27
|
||||||
version: 18.0.28
|
version: 18.0.28
|
||||||
@ -853,8 +872,8 @@ packages:
|
|||||||
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
|
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@tauri-apps/cli-darwin-arm64@1.2.3:
|
/@tauri-apps/cli-darwin-arm64@1.3.1:
|
||||||
resolution: {integrity: sha512-phJN3fN8FtZZwqXg08bcxfq1+X1JSDglLvRxOxB7VWPq+O5SuB8uLyssjJsu+PIhyZZnIhTGdjhzLSFhSXfLsw==}
|
resolution: {integrity: sha512-QlepYVPgOgspcwA/u4kGG4ZUijlXfdRtno00zEy+LxinN/IRXtk+6ErVtsmoLi1ZC9WbuMwzAcsRvqsD+RtNAg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
@ -862,8 +881,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-darwin-x64@1.2.3:
|
/@tauri-apps/cli-darwin-x64@1.3.1:
|
||||||
resolution: {integrity: sha512-jFZ/y6z8z6v4yliIbXKBXA7BJgtZVMsITmEXSuD6s5+eCOpDhQxbRkr6CA+FFfr+/r96rWSDSgDenDQuSvPAKw==}
|
resolution: {integrity: sha512-fKcAUPVFO3jfDKXCSDGY0MhZFF/wDtx3rgFnogWYu4knk38o9RaqRkvMvqJhLYPuWaEM5h6/z1dRrr9KKCbrVg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
@ -871,8 +890,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-linux-arm-gnueabihf@1.2.3:
|
/@tauri-apps/cli-linux-arm-gnueabihf@1.3.1:
|
||||||
resolution: {integrity: sha512-C7h5vqAwXzY0kRGSU00Fj8PudiDWFCiQqqUNI1N+fhCILrzWZB9TPBwdx33ZfXKt/U4+emdIoo/N34v3TiAOmQ==}
|
resolution: {integrity: sha512-+4H0dv8ltJHYu/Ma1h9ixUPUWka9EjaYa8nJfiMsdCI4LJLNE6cPveE7RmhZ59v9GW1XB108/k083JUC/OtGvA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -880,8 +899,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-linux-arm64-gnu@1.2.3:
|
/@tauri-apps/cli-linux-arm64-gnu@1.3.1:
|
||||||
resolution: {integrity: sha512-buf1c8sdkuUzVDkGPQpyUdAIIdn5r0UgXU6+H5fGPq/Xzt5K69JzXaeo6fHsZEZghbV0hOK+taKV4J0m30UUMQ==}
|
resolution: {integrity: sha512-Pj3odVO1JAxLjYmoXKxcrpj/tPxcA8UP8N06finhNtBtBaxAjrjjxKjO4968KB0BUH7AASIss9EL4Tr0FGnDuw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -889,8 +908,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-linux-arm64-musl@1.2.3:
|
/@tauri-apps/cli-linux-arm64-musl@1.3.1:
|
||||||
resolution: {integrity: sha512-x88wPS9W5xAyk392vc4uNHcKBBvCp0wf4H9JFMF9OBwB7vfd59LbQCFcPSu8f0BI7bPrOsyHqspWHuFL8ojQEA==}
|
resolution: {integrity: sha512-tA0JdDLPFaj42UDIVcF2t8V0tSha40rppcmAR/MfQpTCxih6399iMjwihz9kZE1n4b5O4KTq9GliYo50a8zYlQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -898,8 +917,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-linux-x64-gnu@1.2.3:
|
/@tauri-apps/cli-linux-x64-gnu@1.3.1:
|
||||||
resolution: {integrity: sha512-ZMz1jxEVe0B4/7NJnlPHmwmSIuwiD6ViXKs8F+OWWz2Y4jn5TGxWKFg7DLx5OwQTRvEIZxxT7lXHi5CuTNAxKg==}
|
resolution: {integrity: sha512-FDU+Mnvk6NLkqQimcNojdKpMN4Y3W51+SQl+NqG9AFCWprCcSg62yRb84751ujZuf2MGT8HQOfmd0i77F4Q3tQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -907,8 +926,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-linux-x64-musl@1.2.3:
|
/@tauri-apps/cli-linux-x64-musl@1.3.1:
|
||||||
resolution: {integrity: sha512-B/az59EjJhdbZDzawEVox0LQu2ZHCZlk8rJf85AMIktIUoAZPFbwyiUv7/zjzA/sY6Nb58OSJgaPL2/IBy7E0A==}
|
resolution: {integrity: sha512-MpO3akXFmK8lZYEbyQRDfhdxz1JkTBhonVuz5rRqxwA7gnGWHa1aF1+/2zsy7ahjB2tQ9x8DDFDMdVE20o9HrA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -916,8 +935,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-win32-ia32-msvc@1.2.3:
|
/@tauri-apps/cli-win32-ia32-msvc@1.3.1:
|
||||||
resolution: {integrity: sha512-ypdO1OdC5ugNJAKO2m3sb1nsd+0TSvMS9Tr5qN/ZSMvtSduaNwrcZ3D7G/iOIanrqu/Nl8t3LYlgPZGBKlw7Ng==}
|
resolution: {integrity: sha512-9Boeo3K5sOrSBAZBuYyGkpV2RfnGQz3ZhGJt4hE6P+HxRd62lS6+qDKAiw1GmkZ0l1drc2INWrNeT50gwOKwIQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@ -925,8 +944,8 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli-win32-x64-msvc@1.2.3:
|
/@tauri-apps/cli-win32-x64-msvc@1.3.1:
|
||||||
resolution: {integrity: sha512-CsbHQ+XhnV/2csOBBDVfH16cdK00gNyNYUW68isedmqcn8j+s0e9cQ1xXIqi+Hue3awp8g3ImYN5KPepf3UExw==}
|
resolution: {integrity: sha512-wMrTo91hUu5CdpbElrOmcZEoJR4aooTG+fbtcc87SMyPGQy1Ux62b+ZdwLvL1sVTxnIm//7v6QLRIWGiUjCPwA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@ -934,20 +953,20 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@tauri-apps/cli@1.2.3:
|
/@tauri-apps/cli@1.3.1:
|
||||||
resolution: {integrity: sha512-erxtXuPhMEGJPBtnhPILD4AjuT81GZsraqpFvXAmEJZ2p8P6t7MVBifCL8LznRknznM3jn90D3M8RNBP3wcXTw==}
|
resolution: {integrity: sha512-o4I0JujdITsVRm3/0spfJX7FcKYrYV1DXJqzlWIn6IY25/RltjU6qbC1TPgVww3RsRX63jyVUTcWpj5wwFl+EQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@tauri-apps/cli-darwin-arm64': 1.2.3
|
'@tauri-apps/cli-darwin-arm64': 1.3.1
|
||||||
'@tauri-apps/cli-darwin-x64': 1.2.3
|
'@tauri-apps/cli-darwin-x64': 1.3.1
|
||||||
'@tauri-apps/cli-linux-arm-gnueabihf': 1.2.3
|
'@tauri-apps/cli-linux-arm-gnueabihf': 1.3.1
|
||||||
'@tauri-apps/cli-linux-arm64-gnu': 1.2.3
|
'@tauri-apps/cli-linux-arm64-gnu': 1.3.1
|
||||||
'@tauri-apps/cli-linux-arm64-musl': 1.2.3
|
'@tauri-apps/cli-linux-arm64-musl': 1.3.1
|
||||||
'@tauri-apps/cli-linux-x64-gnu': 1.2.3
|
'@tauri-apps/cli-linux-x64-gnu': 1.3.1
|
||||||
'@tauri-apps/cli-linux-x64-musl': 1.2.3
|
'@tauri-apps/cli-linux-x64-musl': 1.3.1
|
||||||
'@tauri-apps/cli-win32-ia32-msvc': 1.2.3
|
'@tauri-apps/cli-win32-ia32-msvc': 1.3.1
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 1.2.3
|
'@tauri-apps/cli-win32-x64-msvc': 1.3.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/parse-json@4.0.0:
|
/@types/parse-json@4.0.0:
|
||||||
@ -1146,6 +1165,10 @@ packages:
|
|||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/immer@10.0.2:
|
||||||
|
resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/import-fresh@3.3.0:
|
/import-fresh@3.3.0:
|
||||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -1163,6 +1186,40 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has: 1.0.3
|
has: 1.0.3
|
||||||
|
|
||||||
|
/jotai-immer@0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-hahK8EPiROS9RoNWmX/Z8rY9WkAijspX4BZ1O7umpcwI4kPNkbcCpu/PhiQ8FMcpEcF6KmbpbMpSSj/GFmo8NA==}
|
||||||
|
peerDependencies:
|
||||||
|
immer: '*'
|
||||||
|
jotai: '>=1.11.0'
|
||||||
|
react: '>=17.0.0'
|
||||||
|
dependencies:
|
||||||
|
immer: 10.0.2
|
||||||
|
jotai: 2.1.1(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/jotai-optics@0.3.0(jotai@2.1.1)(optics-ts@2.4.0):
|
||||||
|
resolution: {integrity: sha512-5ttpCRREIBu6DJix0wlyBP6y1QDPlePnoMZSXNDi/FOkXZrhk9uIXKjwvw34/yBCHT5mYpFUD4sFDvRUU2vkvQ==}
|
||||||
|
peerDependencies:
|
||||||
|
jotai: '>=1.11.0'
|
||||||
|
optics-ts: '*'
|
||||||
|
dependencies:
|
||||||
|
jotai: 2.1.1(react@18.2.0)
|
||||||
|
optics-ts: 2.4.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/jotai@2.1.1(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-LaaiuSaq+6XkwkrCtCkczyFVZOXe0dfjAFN4DVMsSZSRv/A/4xuLHnlpHMEDqvngjWYBotTIrnQ7OogMkUE6wA==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/js-tokens@4.0.0:
|
/js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -1193,6 +1250,10 @@ packages:
|
|||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/optics-ts@2.4.0:
|
||||||
|
resolution: {integrity: sha512-BIYgnqOTEf+WiXuxuBFXeoCtyIDOwnUwCMybdQh8qdHyWXunwVVt7iD9XwNq8SCd5vUo9vqgYxF5ati/6inIuQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/parent-module@1.0.1:
|
/parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
12
gpgui/public/auth.html
Normal file
12
gpgui/public/auth.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GlobalProtect Login</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -15,13 +15,20 @@ rust-version = "1.59"
|
|||||||
tauri-build = { version = "1.3", features = [] }
|
tauri-build = { version = "1.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "1.3", features = ["http-all"] }
|
gpcommon = { path = "../../gpcommon" }
|
||||||
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri = { version = "1.3", features = ["http-all", "process-exit", "shell-open", "window-all", "window-data-url"] }
|
||||||
|
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
|
||||||
|
"colored",
|
||||||
|
] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.10"
|
env_logger = "0.10"
|
||||||
common = { path = "../../common" }
|
webkit2gtk = "0.18.2"
|
||||||
|
regex = "1"
|
||||||
|
url = "2.3"
|
||||||
|
tokio = { version = "1.14", features = ["full"] }
|
||||||
|
veil = "0.1.6"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
467
gpgui/src-tauri/src/auth.rs
Normal file
467
gpgui/src-tauri/src/auth.rs
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
use crate::utils::{clear_webview_cookies, redact_url};
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::de::Error;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tauri::{AppHandle, Manager, Window, WindowUrl};
|
||||||
|
use tauri::{EventHandler, WindowEvent};
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use veil::Redact;
|
||||||
|
use webkit2gtk::gio::Cancellable;
|
||||||
|
use webkit2gtk::glib::GString;
|
||||||
|
use webkit2gtk::traits::{URIResponseExt, WebViewExt};
|
||||||
|
use webkit2gtk::{LoadEvent, WebResource, WebResourceExt};
|
||||||
|
|
||||||
|
const AUTH_WINDOW_LABEL: &str = "auth_window";
|
||||||
|
const AUTH_ERROR_EVENT: &str = "auth-error";
|
||||||
|
const AUTH_REQUEST_EVENT: &str = "auth-request";
|
||||||
|
// Timeout to show the window if the token is not found in the response
|
||||||
|
// It will be cancelled if the token is found in the response
|
||||||
|
const SHOW_WINDOW_TIMEOUT: u64 = 3;
|
||||||
|
// A fallback timeout to show the window in case the authentication process takes longer than expected
|
||||||
|
const FALLBACK_SHOW_WINDOW_TIMEOUT: u64 = 15;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub(crate) enum SamlBinding {
|
||||||
|
#[serde(rename = "REDIRECT")]
|
||||||
|
Redirect,
|
||||||
|
#[serde(rename = "POST")]
|
||||||
|
Post,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Redact, Clone, Deserialize)]
|
||||||
|
pub(crate) struct AuthRequest {
|
||||||
|
#[serde(alias = "samlBinding")]
|
||||||
|
saml_binding: SamlBinding,
|
||||||
|
#[redact(fixed = 10)]
|
||||||
|
#[serde(alias = "samlRequest")]
|
||||||
|
saml_request: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRequest {
|
||||||
|
pub fn new(saml_binding: SamlBinding, saml_request: String) -> Self {
|
||||||
|
Self {
|
||||||
|
saml_binding,
|
||||||
|
saml_request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Option<&str>> for AuthRequest {
|
||||||
|
type Error = serde_json::Error;
|
||||||
|
|
||||||
|
fn try_from(value: Option<&str>) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
Some(value) => serde_json::from_str(value),
|
||||||
|
None => Err(Error::custom("No auth request provided")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Redact, Clone, Serialize)]
|
||||||
|
pub(crate) struct AuthData {
|
||||||
|
#[redact]
|
||||||
|
username: Option<String>,
|
||||||
|
#[redact(fixed = 10)]
|
||||||
|
prelogin_cookie: Option<String>,
|
||||||
|
#[redact(fixed = 10)]
|
||||||
|
portal_userauthcookie: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthData {
|
||||||
|
fn new(
|
||||||
|
username: Option<String>,
|
||||||
|
prelogin_cookie: Option<String>,
|
||||||
|
portal_userauthcookie: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
username,
|
||||||
|
prelogin_cookie,
|
||||||
|
portal_userauthcookie,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self) -> bool {
|
||||||
|
self.username.is_some()
|
||||||
|
&& (self.prelogin_cookie.is_some() || self.portal_userauthcookie.is_some())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AuthError {
|
||||||
|
TokenNotFound,
|
||||||
|
TokenInvalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AuthEvent {
|
||||||
|
Request(AuthRequest),
|
||||||
|
Success(AuthData),
|
||||||
|
Error(AuthError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SamlLoginParams {
|
||||||
|
pub auth_request: AuthRequest,
|
||||||
|
pub user_agent: String,
|
||||||
|
pub clear_cookies: bool,
|
||||||
|
pub app_handle: AppHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn saml_login(params: SamlLoginParams) -> tauri::Result<Option<AuthData>> {
|
||||||
|
info!("Starting SAML login");
|
||||||
|
|
||||||
|
let (auth_event_tx, auth_event_rx) = mpsc::channel::<AuthEvent>(1);
|
||||||
|
let window = build_window(¶ms.app_handle, ¶ms.user_agent)?;
|
||||||
|
setup_webview(&window, auth_event_tx.clone())?;
|
||||||
|
let handler = setup_window(&window, auth_event_tx);
|
||||||
|
|
||||||
|
if params.clear_cookies {
|
||||||
|
if let Err(err) = clear_webview_cookies(&window).await {
|
||||||
|
warn!("Failed to clear webview cookies: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = process(&window, params.auth_request, auth_event_rx).await;
|
||||||
|
window.unlisten(handler);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
|
||||||
|
let url = WindowUrl::App("auth.html".into());
|
||||||
|
Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
|
||||||
|
.visible(false)
|
||||||
|
.title("GlobalProtect Login")
|
||||||
|
.inner_size(390.0, 694.0)
|
||||||
|
.min_inner_size(390.0, 600.0)
|
||||||
|
.user_agent(ua)
|
||||||
|
.always_on_top(true)
|
||||||
|
.focused(true)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup webview events
|
||||||
|
fn setup_webview(window: &Window, auth_event_tx: mpsc::Sender<AuthEvent>) -> tauri::Result<()> {
|
||||||
|
window.with_webview(move |wv| {
|
||||||
|
let wv = wv.inner();
|
||||||
|
let auth_event_tx_clone = auth_event_tx.clone();
|
||||||
|
|
||||||
|
wv.connect_load_changed(move |wv, event| {
|
||||||
|
if LoadEvent::Finished != event {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri = wv.uri().unwrap_or("".into());
|
||||||
|
// Empty URI indicates that an error occurred
|
||||||
|
if uri.is_empty() {
|
||||||
|
warn!("Empty URI loaded, retrying");
|
||||||
|
send_auth_error(auth_event_tx_clone.clone(), AuthError::TokenInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!("Loaded URI: {}", redact_url(&uri));
|
||||||
|
|
||||||
|
if let Some(main_res) = wv.main_resource() {
|
||||||
|
parse_auth_data(&main_res, auth_event_tx_clone.clone());
|
||||||
|
} else {
|
||||||
|
warn!("No main_resource");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wv.connect_load_failed(move |_wv, event, _uri, err| {
|
||||||
|
warn!("Load failed: {:?}, {:?}", event, err);
|
||||||
|
send_auth_error(auth_event_tx.clone(), AuthError::TokenInvalid);
|
||||||
|
false
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHandler {
|
||||||
|
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
|
||||||
|
if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) {
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
send_auth_event(event_tx.clone(), AuthEvent::Request(payload));
|
||||||
|
} else {
|
||||||
|
warn!("Invalid auth request payload");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process(
|
||||||
|
window: &Window,
|
||||||
|
auth_request: AuthRequest,
|
||||||
|
event_rx: mpsc::Receiver<AuthEvent>,
|
||||||
|
) -> tauri::Result<Option<AuthData>> {
|
||||||
|
info!("Processing auth request: {:?}", auth_request);
|
||||||
|
|
||||||
|
process_request(window, auth_request)?;
|
||||||
|
|
||||||
|
let handle = tokio::spawn(show_window_after_timeout(window.clone()));
|
||||||
|
let auth_data = monitor_events(&window, event_rx).await;
|
||||||
|
|
||||||
|
if !handle.is_finished() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
Ok(auth_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_request(window: &Window, auth_request: AuthRequest) -> tauri::Result<()> {
|
||||||
|
let saml_request = auth_request.saml_request;
|
||||||
|
let is_post = matches!(auth_request.saml_binding, SamlBinding::Post);
|
||||||
|
|
||||||
|
window.with_webview(move |wv| {
|
||||||
|
let wv = wv.inner();
|
||||||
|
if is_post {
|
||||||
|
// Load SAML request as HTML if POST binding is used
|
||||||
|
info!("Loading SAML request as HTML");
|
||||||
|
wv.load_html(&saml_request, None);
|
||||||
|
} else {
|
||||||
|
// Redirect to SAML request URL if REDIRECT binding is used
|
||||||
|
info!("Redirecting to SAML request URL");
|
||||||
|
wv.load_uri(&saml_request);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show_window_after_timeout(window: Window) {
|
||||||
|
tokio::time::sleep(Duration::from_secs(FALLBACK_SHOW_WINDOW_TIMEOUT)).await;
|
||||||
|
info!(
|
||||||
|
"Showing window after timeout ({:?} seconds)",
|
||||||
|
FALLBACK_SHOW_WINDOW_TIMEOUT
|
||||||
|
);
|
||||||
|
show_window(&window);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor_events(window: &Window, event_rx: mpsc::Receiver<AuthEvent>) -> Option<AuthData> {
|
||||||
|
tokio::select! {
|
||||||
|
auth_data = monitor_auth_event(window, event_rx) => Some(auth_data),
|
||||||
|
_ = monitor_window_close_event(window) => {
|
||||||
|
warn!("Auth window closed without auth data");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor_auth_event(window: &Window, mut event_rx: mpsc::Receiver<AuthEvent>) -> AuthData {
|
||||||
|
info!("Monitoring auth events");
|
||||||
|
|
||||||
|
let (cancel_timeout_tx, cancel_timeout_rx) = mpsc::channel::<()>(1);
|
||||||
|
let cancel_timeout_rx = Arc::new(Mutex::new(cancel_timeout_rx));
|
||||||
|
let mut attempt_times = 1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(auth_event) = event_rx.recv().await {
|
||||||
|
match auth_event {
|
||||||
|
AuthEvent::Request(auth_request) => {
|
||||||
|
attempt_times = attempt_times + 1;
|
||||||
|
info!(
|
||||||
|
"Got auth request from auth-request event, attempt #{}",
|
||||||
|
attempt_times
|
||||||
|
);
|
||||||
|
if let Err(err) = process_request(&window, auth_request) {
|
||||||
|
warn!("Error processing auth request: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthEvent::Success(auth_data) => {
|
||||||
|
info!("Got auth data successfully, closing window");
|
||||||
|
close_window(window);
|
||||||
|
return auth_data;
|
||||||
|
}
|
||||||
|
AuthEvent::Error(AuthError::TokenInvalid) => {
|
||||||
|
// Found the invalid token, means that user is authenticated, keep retrying and no need to show the window
|
||||||
|
warn!(
|
||||||
|
"Attempt #{} failed, found invalid token, retrying",
|
||||||
|
attempt_times
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the cancel timeout is locked, it means that the window is about to show, so we need to cancel it
|
||||||
|
if cancel_timeout_rx.try_lock().is_err() {
|
||||||
|
if let Err(err) = cancel_timeout_tx.try_send(()) {
|
||||||
|
warn!("Error sending cancel timeout: {}", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("Window is not about to show, skipping cancel timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the error event to the outside, so that we can retry it when receiving the auth-request event
|
||||||
|
if let Err(err) = window.emit_all(AUTH_ERROR_EVENT, attempt_times) {
|
||||||
|
warn!("Error emitting auth-error event: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthEvent::Error(AuthError::TokenNotFound) => {
|
||||||
|
let window_visible = window.is_visible().unwrap_or(false);
|
||||||
|
if window_visible {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Token not found, showing window in {} seconds",
|
||||||
|
SHOW_WINDOW_TIMEOUT
|
||||||
|
);
|
||||||
|
|
||||||
|
let cancel_timeout_rx = cancel_timeout_rx.clone();
|
||||||
|
tokio::spawn(handle_token_not_found(window.clone(), cancel_timeout_rx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor_window_close_event(window: &Window) {
|
||||||
|
let (close_tx, close_rx) = oneshot::channel();
|
||||||
|
let close_tx = Arc::new(Mutex::new(Some(close_tx)));
|
||||||
|
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if matches!(event, WindowEvent::CloseRequested { .. }) {
|
||||||
|
if let Ok(mut close_tx_locked) = close_tx.try_lock() {
|
||||||
|
if let Some(close_tx) = close_tx_locked.take() {
|
||||||
|
if let Err(_) = close_tx.send(()) {
|
||||||
|
println!("Error sending close event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(err) = close_rx.await {
|
||||||
|
warn!("Error receiving close event: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Tokens not found means that the page might need the user interaction to login,
|
||||||
|
/// we should show the window after a short timeout, it will be cancelled if the
|
||||||
|
/// token is found in the response, no matter it's valid or not.
|
||||||
|
async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc<Mutex<mpsc::Receiver<()>>>) {
|
||||||
|
if let Ok(mut cancel_timeout_rx) = cancel_timeout_rx.try_lock() {
|
||||||
|
let duration = Duration::from_secs(SHOW_WINDOW_TIMEOUT);
|
||||||
|
if timeout(duration, cancel_timeout_rx.recv()).await.is_err() {
|
||||||
|
info!(
|
||||||
|
"Timeout expired after {} seconds, showing window",
|
||||||
|
SHOW_WINDOW_TIMEOUT
|
||||||
|
);
|
||||||
|
show_window(&window);
|
||||||
|
} else {
|
||||||
|
info!("The scheduled show window task is cancelled");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("The show window task has been already been scheduled, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the authentication data from the response headers or HTML content
|
||||||
|
/// and send it to the event channel
|
||||||
|
fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent>) {
|
||||||
|
if let Some(response) = main_res.response() {
|
||||||
|
if let Some(auth_data) = read_auth_data_from_response(&response) {
|
||||||
|
debug!("Got auth data from HTTP headers: {:?}", auth_data);
|
||||||
|
send_auth_data(auth_event_tx, auth_data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_event_tx = auth_event_tx.clone();
|
||||||
|
main_res.data(Cancellable::NONE, move |data| {
|
||||||
|
if let Ok(data) = data {
|
||||||
|
let html = String::from_utf8_lossy(&data);
|
||||||
|
match read_auth_data_from_html(&html) {
|
||||||
|
Ok(auth_data) => {
|
||||||
|
debug!("Got auth data from HTML: {:?}", auth_data);
|
||||||
|
send_auth_data(auth_event_tx, auth_data);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!("Error reading auth data from HTML: {:?}", err);
|
||||||
|
send_auth_error(auth_event_tx, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the authentication data from the response headers
|
||||||
|
fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<AuthData> {
|
||||||
|
response.http_headers().and_then(|mut headers| {
|
||||||
|
let auth_data = AuthData::new(
|
||||||
|
headers.get("saml-username").map(GString::into),
|
||||||
|
headers.get("prelogin-cookie").map(GString::into),
|
||||||
|
headers.get("portal-userauthcookie").map(GString::into),
|
||||||
|
);
|
||||||
|
|
||||||
|
if auth_data.check() {
|
||||||
|
Some(auth_data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the authentication data from the HTML content
|
||||||
|
fn read_auth_data_from_html(html: &str) -> Result<AuthData, AuthError> {
|
||||||
|
if html.contains("Temporarily Unavailable") {
|
||||||
|
info!("SAML result page temporarily unavailable, retrying");
|
||||||
|
return Err(AuthError::TokenInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let saml_auth_status = parse_xml_tag(html, "saml-auth-status");
|
||||||
|
|
||||||
|
match saml_auth_status {
|
||||||
|
Some(status) if status == "1" => extract_auth_data(html).ok_or(AuthError::TokenInvalid),
|
||||||
|
Some(status) if status == "-1" => Err(AuthError::TokenInvalid),
|
||||||
|
_ => Err(AuthError::TokenNotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the authentication data from the HTML content
|
||||||
|
fn extract_auth_data(html: &str) -> Option<AuthData> {
|
||||||
|
let auth_data = AuthData::new(
|
||||||
|
parse_xml_tag(html, "saml-username"),
|
||||||
|
parse_xml_tag(html, "prelogin-cookie"),
|
||||||
|
parse_xml_tag(html, "portal-userauthcookie"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if auth_data.check() {
|
||||||
|
Some(auth_data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
|
||||||
|
let re = Regex::new(&format!("<{}>(.*)</{}>", tag, tag)).unwrap();
|
||||||
|
re.captures(html)
|
||||||
|
.and_then(|captures| captures.get(1))
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_auth_data(auth_event_tx: mpsc::Sender<AuthEvent>, auth_data: AuthData) {
|
||||||
|
send_auth_event(auth_event_tx, AuthEvent::Success(auth_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_auth_error(auth_event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
|
||||||
|
send_auth_event(auth_event_tx, AuthEvent::Error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_auth_event(auth_event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) {
|
||||||
|
let _ = tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = auth_event_tx.send(auth_event).await {
|
||||||
|
warn!("Error sending event: {}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_window(window: &Window) {
|
||||||
|
let visible = window.is_visible().unwrap_or(false);
|
||||||
|
if visible {
|
||||||
|
debug!("Window is already visible, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = window.show() {
|
||||||
|
warn!("Error showing window: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_window(window: &Window) {
|
||||||
|
if let Err(err) = window.close() {
|
||||||
|
warn!("Error closing window: {}", err);
|
||||||
|
}
|
||||||
|
}
|
49
gpgui/src-tauri/src/commands.rs
Normal file
49
gpgui/src-tauri/src/commands.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
use crate::auth::{self, AuthData, AuthRequest, SamlBinding, SamlLoginParams};
|
||||||
|
use gpcommon::{Client, ServerApiError, VpnStatus};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn service_online<'a>(client: State<'a, Arc<Client>>) -> Result<bool, ()> {
|
||||||
|
Ok(client.is_online().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn vpn_status<'a>(
|
||||||
|
client: State<'a, Arc<Client>>,
|
||||||
|
) -> Result<VpnStatus, ServerApiError> {
|
||||||
|
client.status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn vpn_connect<'a>(
|
||||||
|
server: String,
|
||||||
|
cookie: String,
|
||||||
|
client: State<'a, Arc<Client>>,
|
||||||
|
) -> Result<(), ServerApiError> {
|
||||||
|
client.connect(server, cookie).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn vpn_disconnect<'a>(
|
||||||
|
client: State<'a, Arc<Client>>,
|
||||||
|
) -> Result<(), ServerApiError> {
|
||||||
|
client.disconnect().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn saml_login(
|
||||||
|
binding: SamlBinding,
|
||||||
|
request: String,
|
||||||
|
clear_cookies: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> tauri::Result<Option<AuthData>> {
|
||||||
|
let user_agent = String::from("PAN GlobalProtect");
|
||||||
|
let params = SamlLoginParams {
|
||||||
|
auth_request: AuthRequest::new(binding, request),
|
||||||
|
user_agent,
|
||||||
|
clear_cookies,
|
||||||
|
app_handle,
|
||||||
|
};
|
||||||
|
auth::saml_login(params).await
|
||||||
|
}
|
@ -3,34 +3,20 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use common::{Client, ServerApiError, VpnStatus};
|
|
||||||
use env_logger::Env;
|
use env_logger::Env;
|
||||||
|
use gpcommon::{Client, ClientStatus, VpnStatus};
|
||||||
|
use log::warn;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{Manager, State};
|
use tauri::Manager;
|
||||||
use tauri_plugin_log::LogTarget;
|
use tauri_plugin_log::LogTarget;
|
||||||
|
|
||||||
#[tauri::command]
|
mod auth;
|
||||||
async fn vpn_status<'a>(client: State<'a, Arc<Client>>) -> Result<VpnStatus, ServerApiError> {
|
mod commands;
|
||||||
client.status().await
|
mod utils;
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn vpn_connect<'a>(
|
|
||||||
server: String,
|
|
||||||
cookie: String,
|
|
||||||
client: State<'a, Arc<Client>>,
|
|
||||||
) -> Result<(), ServerApiError> {
|
|
||||||
client.connect(server, cookie).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn vpn_disconnect<'a>(client: State<'a, Arc<Client>>) -> Result<(), ServerApiError> {
|
|
||||||
client.disconnect().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct StatusPayload {
|
struct VpnStatusPayload {
|
||||||
status: VpnStatus,
|
status: VpnStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,10 +26,17 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let app_handle = app.handle();
|
let app_handle = app.handle();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let _ = client_clone.subscribe_status(move |status| {
|
let _ = client_clone.subscribe_status(move |client_status| match client_status {
|
||||||
let payload = StatusPayload { status };
|
ClientStatus::Vpn(vpn_status) => {
|
||||||
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
|
let payload = VpnStatusPayload { status: vpn_status };
|
||||||
println!("Error emmiting event: {}", err);
|
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
|
||||||
|
warn!("Error emitting event: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientStatus::Service(is_online) => {
|
||||||
|
if let Err(err) = app_handle.emit_all("service-status-changed", is_online) {
|
||||||
|
warn!("Error emitting event: {}", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,13 +57,17 @@ fn main() {
|
|||||||
LogTarget::LogDir,
|
LogTarget::LogDir,
|
||||||
LogTarget::Stdout, /*LogTarget::Webview*/
|
LogTarget::Stdout, /*LogTarget::Webview*/
|
||||||
])
|
])
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.with_colors(Default::default())
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.setup(setup)
|
.setup(setup)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
vpn_status,
|
commands::service_online,
|
||||||
vpn_connect,
|
commands::vpn_status,
|
||||||
vpn_disconnect
|
commands::vpn_connect,
|
||||||
|
commands::vpn_disconnect,
|
||||||
|
commands::saml_login
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
88
gpgui/src-tauri/src/utils.rs
Normal file
88
gpgui/src-tauri/src/utils.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use log::{info, warn};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::Window;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use url::{form_urlencoded, Url};
|
||||||
|
use webkit2gtk::{
|
||||||
|
gio::Cancellable, glib::TimeSpan, WebContextExt, WebViewExt, WebsiteDataManagerExtManual,
|
||||||
|
WebsiteDataTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn redact_url(url: &str) -> String {
|
||||||
|
if let Ok(mut url) = Url::parse(&url) {
|
||||||
|
if let Err(err) = url.set_host(Some("redacted")) {
|
||||||
|
warn!("Error redacting URL: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = url.query().unwrap_or_default();
|
||||||
|
if !query.is_empty() {
|
||||||
|
// Replace the query value with <redacted> for each key.
|
||||||
|
let redacted_query = redact_query(url.query().unwrap_or(""));
|
||||||
|
url.set_query(Some(&redacted_query));
|
||||||
|
}
|
||||||
|
return url.to_string();
|
||||||
|
} else {
|
||||||
|
warn!("Error parsing URL: {}", url);
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_query(query: &str) -> String {
|
||||||
|
let query_pairs = form_urlencoded::parse(query.as_bytes());
|
||||||
|
let mut redacted_pairs = query_pairs.map(|(key, _)| (key, "__redacted__"));
|
||||||
|
|
||||||
|
form_urlencoded::Serializer::new(String::new())
|
||||||
|
.extend_pairs(redacted_pairs.by_ref())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn clear_webview_cookies(window: &Window) -> Result<(), tauri::Error> {
|
||||||
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
|
|
||||||
|
window.with_webview(|wv| {
|
||||||
|
let wv = wv.inner();
|
||||||
|
let context = match wv.context() {
|
||||||
|
Some(context) => context,
|
||||||
|
None => {
|
||||||
|
return send_error(tx, "No context found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let data_manager = match context.website_data_manager() {
|
||||||
|
Some(manager) => manager,
|
||||||
|
None => {
|
||||||
|
return send_error(tx, "No data manager found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
data_manager.clear(
|
||||||
|
WebsiteDataTypes::COOKIES,
|
||||||
|
TimeSpan(0),
|
||||||
|
Cancellable::NONE,
|
||||||
|
move |result| match result {
|
||||||
|
Err(err) => {
|
||||||
|
send_error(tx, &err.to_string());
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Cookies cleared in {} ms", now.elapsed().as_millis());
|
||||||
|
send_result(tx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rx.await.map_err(|_| tauri::Error::FailedToSendMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_error(tx: oneshot::Sender<()>, message: &str) {
|
||||||
|
warn!("Error clearing cookies: {}", message);
|
||||||
|
if tx.send(()).is_err() {
|
||||||
|
warn!("Error sending clear cookies result");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_result(tx: oneshot::Sender<()>) {
|
||||||
|
if tx.send(()).is_err() {
|
||||||
|
warn!("Error sending clear cookies result");
|
||||||
|
}
|
||||||
|
}
|
@ -12,10 +12,19 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
|
"shell": {
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
"http": {
|
"http": {
|
||||||
"all": true,
|
"all": true,
|
||||||
"request": true,
|
"request": true,
|
||||||
"scope": ["https://**"]
|
"scope": ["https://**"]
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"exit": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
html {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
@ -1,209 +1,49 @@
|
|||||||
import { Box, TextField } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
import { useAtomValue } from "jotai";
|
||||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import ConnectionStatus, { Status } from "./components/ConnectionStatus";
|
import { statusReadyAtom } from "./atoms/status";
|
||||||
import Notification, { NotificationConfig } from "./components/Notification";
|
import ConnectForm from "./components/ConnectForm";
|
||||||
import PasswordAuth, {
|
import ConnectionStatus from "./components/ConnectionStatus";
|
||||||
Credentials,
|
import Feedback from "./components/Feedback";
|
||||||
PasswordAuthData,
|
import GatewaySwitcher from "./components/GatewaySwitcher";
|
||||||
} from "./components/PasswordAuth";
|
import MainMenu from "./components/MainMenu";
|
||||||
import gatewayService from "./services/gatewayService";
|
import Notification from "./components/Notification";
|
||||||
import portalService from "./services/portalService";
|
|
||||||
import vpnService from "./services/vpnService";
|
|
||||||
|
|
||||||
export default function App() {
|
function Loading() {
|
||||||
const [portalAddress, setPortalAddress] = useState("220.191.185.154");
|
|
||||||
const [status, setStatus] = useState<Status>("disconnected");
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [passwordAuthOpen, setPasswordAuthOpen] = useState(false);
|
|
||||||
const [passwordAuthenticating, setPasswordAuthenticating] = useState(false);
|
|
||||||
const [passwordAuth, setPasswordAuth] = useState<PasswordAuthData>();
|
|
||||||
const [notification, setNotification] = useState<NotificationConfig>({
|
|
||||||
open: false,
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return vpnService.onStatusChanged((latestStatus) => {
|
|
||||||
console.log("status changed", latestStatus);
|
|
||||||
setStatus(latestStatus);
|
|
||||||
if (latestStatus === "connected") {
|
|
||||||
clearOverlays();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function closeNotification() {
|
|
||||||
setNotification((notification) => ({
|
|
||||||
...notification,
|
|
||||||
open: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearOverlays() {
|
|
||||||
closeNotification();
|
|
||||||
setPasswordAuthenticating(false);
|
|
||||||
setPasswordAuthOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePortalChange(e: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const { value } = e.target;
|
|
||||||
setPortalAddress(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConnect(e: FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
setProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await portalService.prelogin(portalAddress);
|
|
||||||
|
|
||||||
if (portalService.isSamlAuth(response)) {
|
|
||||||
// TODO SAML login
|
|
||||||
} else if (portalService.isPasswordAuth(response)) {
|
|
||||||
setPasswordAuthOpen(true);
|
|
||||||
setPasswordAuth({
|
|
||||||
authMessage: response.authMessage,
|
|
||||||
labelPassword: response.labelPassword,
|
|
||||||
labelUsername: response.labelUsername,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Unsupported portal login method");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
// TODO cancel the request first
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDisconnect() {
|
|
||||||
setProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await vpnService.disconnect();
|
|
||||||
} catch (err: any) {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
type: "error",
|
|
||||||
title: "Failed to disconnect",
|
|
||||||
message: err.message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePasswordAuth({ username, password }: Credentials) {
|
|
||||||
try {
|
|
||||||
setPasswordAuthenticating(true);
|
|
||||||
const portalConfigResponse = await portalService.fetchConfig({
|
|
||||||
portal: portalAddress,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { gateways, preferredGateway, userAuthCookie } =
|
|
||||||
portalConfigResponse;
|
|
||||||
|
|
||||||
if (gateways.length === 0) {
|
|
||||||
// TODO handle no gateways, treat the portal as a gateway
|
|
||||||
throw new Error("No gateways found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await gatewayService.login({
|
|
||||||
gateway: preferredGateway,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
userAuthCookie,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vpnService.connect(preferredGateway.address!, token);
|
|
||||||
setProcessing(false);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
type: "error",
|
|
||||||
title: "Login failed",
|
|
||||||
message: err.message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setPasswordAuthenticating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelPasswordAuth() {
|
|
||||||
setPasswordAuthenticating(false);
|
|
||||||
setPasswordAuthOpen(false);
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Box padding={2} paddingTop={3}>
|
<Box
|
||||||
<ConnectionStatus
|
sx={{
|
||||||
sx={{ mb: 2 }}
|
position: "absolute",
|
||||||
status={processing ? "processing" : status}
|
inset: 0,
|
||||||
/>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
<form onSubmit={handleConnect}>
|
justifyContent: "center",
|
||||||
<TextField
|
}}
|
||||||
autoFocus
|
>
|
||||||
label="Portal address"
|
Loading...
|
||||||
placeholder="Hostname or IP address"
|
</Box>
|
||||||
fullWidth
|
);
|
||||||
size="small"
|
}
|
||||||
value={portalAddress}
|
|
||||||
onChange={handlePortalChange}
|
function MainContent() {
|
||||||
InputProps={{ readOnly: status !== "disconnected" }}
|
return (
|
||||||
/>
|
<>
|
||||||
<Box sx={{ mt: 1.5 }}>
|
<MainMenu />
|
||||||
{status === "disconnected" && (
|
<ConnectionStatus />
|
||||||
<Button
|
<ConnectForm />
|
||||||
type="submit"
|
<GatewaySwitcher />
|
||||||
variant="contained"
|
<Feedback />
|
||||||
fullWidth
|
</>
|
||||||
sx={{ textTransform: "none" }}
|
);
|
||||||
>
|
}
|
||||||
Connect
|
|
||||||
</Button>
|
export default function App() {
|
||||||
)}
|
const ready = useAtomValue(statusReadyAtom);
|
||||||
{status === "connecting" && (
|
|
||||||
<Button
|
return (
|
||||||
variant="outlined"
|
<Box padding={2} paddingBottom={0}>
|
||||||
fullWidth
|
{ready ? <MainContent /> : <Loading />}
|
||||||
onClick={handleCancel}
|
<Notification />
|
||||||
sx={{ textTransform: "none" }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{status === "connected" && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
sx={{ textTransform: "none" }}
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<PasswordAuth
|
|
||||||
open={passwordAuthOpen}
|
|
||||||
authData={passwordAuth}
|
|
||||||
authenticating={passwordAuthenticating}
|
|
||||||
onCancel={cancelPasswordAuth}
|
|
||||||
onLogin={handlePasswordAuth}
|
|
||||||
/>
|
|
||||||
<Notification {...notification} onClose={closeNotification} />
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
65
gpgui/src/atoms/gateway.ts
Normal file
65
gpgui/src/atoms/gateway.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import gatewayService from "../services/gatewayService";
|
||||||
|
import vpnService from "../services/vpnService";
|
||||||
|
import { notifyErrorAtom } from "./notification";
|
||||||
|
import { isProcessingAtom, statusAtom } from "./status";
|
||||||
|
|
||||||
|
type GatewayCredential = {
|
||||||
|
user: string;
|
||||||
|
passwd?: string;
|
||||||
|
userAuthCookie: string;
|
||||||
|
prelogonUserAuthCookie: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gatewayLoginAtom = atom(
|
||||||
|
null,
|
||||||
|
async (get, set, gateway: string, credential: GatewayCredential) => {
|
||||||
|
set(statusAtom, "gateway-login");
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
token = await gatewayService.login(gateway, credential);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to login to gateway");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProcessing = get(isProcessingAtom);
|
||||||
|
if (!isProcessing) {
|
||||||
|
console.info("Request cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await set(connectVpnAtom, gateway, token);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectVpnAtom = atom(
|
||||||
|
null,
|
||||||
|
async (_get, set, vpnAddress: string, token: string) => {
|
||||||
|
try {
|
||||||
|
set(statusAtom, "connecting");
|
||||||
|
await vpnService.connect(vpnAddress, token);
|
||||||
|
set(statusAtom, "connected");
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to connect to VPN");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const disconnectVpnAtom = atom(null, async (get, set) => {
|
||||||
|
try {
|
||||||
|
set(statusAtom, "disconnecting");
|
||||||
|
await vpnService.disconnect();
|
||||||
|
// Sleep a short time, so that the client can receive the service's disconnected event.
|
||||||
|
await sleep(100);
|
||||||
|
} catch (err) {
|
||||||
|
set(statusAtom, "disconnected");
|
||||||
|
set(notifyErrorAtom, "Failed to disconnect from VPN");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gatewaySwitcherVisibleAtom = atom(false);
|
||||||
|
export const openGatewaySwitcherAtom = atom(null, (get, set) => {
|
||||||
|
set(gatewaySwitcherVisibleAtom, true);
|
||||||
|
});
|
20
gpgui/src/atoms/menu.ts
Normal file
20
gpgui/src/atoms/menu.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { exit } from "@tauri-apps/api/process";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { RESET } from "jotai/utils";
|
||||||
|
import { disconnectVpnAtom } from "./gateway";
|
||||||
|
import { appDataStorageAtom, portalAddressAtom } from "./portal";
|
||||||
|
import { statusAtom } from "./status";
|
||||||
|
|
||||||
|
export const resetAtom = atom(null, (_get, set) => {
|
||||||
|
set(appDataStorageAtom, RESET);
|
||||||
|
set(portalAddressAtom, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quitAtom = atom(null, async (get, set) => {
|
||||||
|
const status = get(statusAtom);
|
||||||
|
|
||||||
|
if (status === "connected") {
|
||||||
|
await set(disconnectVpnAtom);
|
||||||
|
}
|
||||||
|
await exit();
|
||||||
|
});
|
61
gpgui/src/atoms/notification.ts
Normal file
61
gpgui/src/atoms/notification.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { AlertColor } from "@mui/material";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export type Severity = AlertColor;
|
||||||
|
|
||||||
|
type NotificationConfig = {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: Severity;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationVisibleAtom = atom(false);
|
||||||
|
export const notificationConfigAtom = atom<NotificationConfig>({
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
severity: "info" as Severity,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const closeNotificationAtom = atom(
|
||||||
|
(get) => get(notificationVisibleAtom),
|
||||||
|
(_get, set) => {
|
||||||
|
set(notificationVisibleAtom, false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const notifyErrorAtom = atom(
|
||||||
|
null,
|
||||||
|
(_get, set, err: unknown, duration: number = 5000) => {
|
||||||
|
let msg: string;
|
||||||
|
if (err instanceof Error) {
|
||||||
|
msg = err.message;
|
||||||
|
} else if (typeof err === "string") {
|
||||||
|
msg = err;
|
||||||
|
} else {
|
||||||
|
msg = "Unknown error";
|
||||||
|
}
|
||||||
|
|
||||||
|
set(notificationVisibleAtom, true);
|
||||||
|
set(notificationConfigAtom, {
|
||||||
|
title: "Error",
|
||||||
|
message: msg,
|
||||||
|
severity: "error",
|
||||||
|
duration: duration <= 0 ? undefined : duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const notifySuccessAtom = atom(
|
||||||
|
null,
|
||||||
|
(_get, set, msg: string, duration: number = 5000) => {
|
||||||
|
set(notificationVisibleAtom, true);
|
||||||
|
set(notificationConfigAtom, {
|
||||||
|
title: "Success",
|
||||||
|
message: msg,
|
||||||
|
severity: "success",
|
||||||
|
duration: duration <= 0 ? undefined : duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
377
gpgui/src/atoms/portal.ts
Normal file
377
gpgui/src/atoms/portal.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { withImmer } from "jotai-immer";
|
||||||
|
import { atomWithDefault, atomWithStorage } from "jotai/utils";
|
||||||
|
import authService, { AuthData } from "../services/authService";
|
||||||
|
import portalService, {
|
||||||
|
PasswordPrelogin,
|
||||||
|
PortalCredential,
|
||||||
|
Prelogin,
|
||||||
|
SamlPrelogin,
|
||||||
|
} from "../services/portalService";
|
||||||
|
import { disconnectVpnAtom, gatewayLoginAtom } from "./gateway";
|
||||||
|
import { notifyErrorAtom } from "./notification";
|
||||||
|
import { isProcessingAtom, statusAtom } from "./status";
|
||||||
|
|
||||||
|
export type GatewayData = {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CachedPortalCredential = Omit<PortalCredential, "prelogin-cookie">;
|
||||||
|
|
||||||
|
type PortalData = {
|
||||||
|
address: string;
|
||||||
|
gateways: GatewayData[];
|
||||||
|
cachedCredential?: CachedPortalCredential;
|
||||||
|
selectedGateway?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppData = {
|
||||||
|
portal: string;
|
||||||
|
portals: PortalData[];
|
||||||
|
clearCookies: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppDataUpdate =
|
||||||
|
| {
|
||||||
|
type: "PORTAL";
|
||||||
|
payload: PortalData;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SELECTED_GATEWAY";
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAppData: AppData = {
|
||||||
|
portal: "",
|
||||||
|
portals: [],
|
||||||
|
// Whether to clear the cookies of the SAML login webview, default is true
|
||||||
|
clearCookies: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appDataStorageAtom = atomWithStorage<AppData>(
|
||||||
|
"APP_DATA",
|
||||||
|
defaultAppData
|
||||||
|
);
|
||||||
|
const appDataImmerAtom = withImmer(appDataStorageAtom);
|
||||||
|
|
||||||
|
const updateAppDataAtom = atom(null, (_get, set, update: AppDataUpdate) => {
|
||||||
|
const { type, payload } = update;
|
||||||
|
switch (type) {
|
||||||
|
case "PORTAL":
|
||||||
|
const { address } = payload;
|
||||||
|
set(appDataImmerAtom, (draft) => {
|
||||||
|
draft.portal = address;
|
||||||
|
const portalIndex = draft.portals.findIndex(
|
||||||
|
({ address: portalAddress }) => portalAddress === address
|
||||||
|
);
|
||||||
|
if (portalIndex === -1) {
|
||||||
|
draft.portals.push(payload);
|
||||||
|
} else {
|
||||||
|
draft.portals[portalIndex] = payload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "SELECTED_GATEWAY":
|
||||||
|
set(appDataImmerAtom, (draft) => {
|
||||||
|
const { portal, portals } = draft;
|
||||||
|
const portalData = portals.find(({ address }) => address === portal);
|
||||||
|
if (portalData) {
|
||||||
|
portalData.selectedGateway = payload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portalAddressAtom = atomWithDefault(
|
||||||
|
(get) => get(appDataImmerAtom).portal
|
||||||
|
);
|
||||||
|
|
||||||
|
export const currentPortalDataAtom = atom<PortalData>((get) => {
|
||||||
|
const portalAddress = get(portalAddressAtom);
|
||||||
|
const { portals } = get(appDataImmerAtom);
|
||||||
|
const portalData = portals.find(({ address }) => address === portalAddress);
|
||||||
|
|
||||||
|
return portalData || { address: portalAddress, gateways: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearCookiesAtom = atom(
|
||||||
|
(get) => get(appDataImmerAtom).clearCookies,
|
||||||
|
(_get, set, update: boolean) => {
|
||||||
|
set(appDataImmerAtom, (draft) => {
|
||||||
|
draft.clearCookies = update;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const portalGatewaysAtom = atom<GatewayData[]>((get) => {
|
||||||
|
const { gateways } = get(currentPortalDataAtom);
|
||||||
|
return gateways;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectedGatewayAtom = atom(
|
||||||
|
(get) => get(currentPortalDataAtom).selectedGateway
|
||||||
|
);
|
||||||
|
|
||||||
|
export const connectPortalAtom = atom(
|
||||||
|
(get) => get(isProcessingAtom),
|
||||||
|
async (get, set, action?: "retry-auth") => {
|
||||||
|
// Retry the SAML authentication
|
||||||
|
if (action === "retry-auth") {
|
||||||
|
set(retrySamlAuthAtom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portal = get(portalAddressAtom);
|
||||||
|
if (!portal) {
|
||||||
|
set(notifyErrorAtom, "Portal is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
set(statusAtom, "prelogin");
|
||||||
|
const prelogin = await portalService.prelogin(portal);
|
||||||
|
const isProcessing = get(isProcessingAtom);
|
||||||
|
if (!isProcessing) {
|
||||||
|
console.info("Request cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await set(loginWithCachedCredentialAtom, prelogin);
|
||||||
|
} catch {
|
||||||
|
if (prelogin.isSamlAuth) {
|
||||||
|
await set(launchSamlAuthAtom, prelogin);
|
||||||
|
} else {
|
||||||
|
await set(launchPasswordAuthAtom, prelogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
set(cancelConnectPortalAtom);
|
||||||
|
set(notifyErrorAtom, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
connectPortalAtom.onMount = (dispatch) => {
|
||||||
|
return authService.onAuthError(() => {
|
||||||
|
dispatch("retry-auth");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginWithCachedCredentialAtom = atom(
|
||||||
|
null,
|
||||||
|
async (get, set, prelogin: Prelogin) => {
|
||||||
|
const { cachedCredential } = get(currentPortalDataAtom);
|
||||||
|
if (!cachedCredential) {
|
||||||
|
throw new Error("No cached credential");
|
||||||
|
}
|
||||||
|
await set(portalLoginAtom, cachedCredential, prelogin);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const passwordPreloginAtom = atom<PasswordPrelogin>({
|
||||||
|
isSamlAuth: false,
|
||||||
|
region: "",
|
||||||
|
authMessage: "",
|
||||||
|
labelUsername: "",
|
||||||
|
labelPassword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelConnectPortalAtom = atom(null, (_get, set) => {
|
||||||
|
set(statusAtom, "disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usernameAtom = atomWithDefault(
|
||||||
|
(get) => get(currentPortalDataAtom).cachedCredential?.user ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
export const passwordAtom = atomWithDefault(
|
||||||
|
(get) => get(currentPortalDataAtom).cachedCredential?.passwd ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordAuthVisibleAtom = atom(false);
|
||||||
|
|
||||||
|
const launchPasswordAuthAtom = atom(
|
||||||
|
null,
|
||||||
|
async (_get, set, prelogin: PasswordPrelogin) => {
|
||||||
|
set(passwordAuthVisibleAtom, true);
|
||||||
|
set(passwordPreloginAtom, prelogin);
|
||||||
|
set(statusAtom, "authenticating-password");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const cancelPasswordAuthAtom = atom(
|
||||||
|
(get) => get(passwordAuthVisibleAtom),
|
||||||
|
(_get, set) => {
|
||||||
|
set(passwordAuthVisibleAtom, false);
|
||||||
|
set(cancelConnectPortalAtom);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const passwordLoginAtom = atom(
|
||||||
|
(get) => get(portalConfigLoadingAtom),
|
||||||
|
async (get, set, username: string, password: string) => {
|
||||||
|
const portal = get(portalAddressAtom);
|
||||||
|
if (!portal) {
|
||||||
|
set(notifyErrorAtom, "Portal is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
set(notifyErrorAtom, "Username is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = { user: username, passwd: password };
|
||||||
|
const prelogin = get(passwordPreloginAtom);
|
||||||
|
await set(portalLoginAtom, credential, prelogin);
|
||||||
|
} catch (err) {
|
||||||
|
set(cancelConnectPortalAtom);
|
||||||
|
set(notifyErrorAtom, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const launchSamlAuthAtom = atom(
|
||||||
|
null,
|
||||||
|
async (get, set, prelogin: SamlPrelogin) => {
|
||||||
|
const { samlAuthMethod, samlRequest } = prelogin;
|
||||||
|
let authData: AuthData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
set(statusAtom, "authenticating-saml");
|
||||||
|
const clearCookies = get(clearCookiesAtom);
|
||||||
|
authData = await authService.samlLogin(
|
||||||
|
samlAuthMethod,
|
||||||
|
samlRequest,
|
||||||
|
clearCookies
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("SAML login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authData) {
|
||||||
|
// User closed the SAML login window, cancel the login
|
||||||
|
set(cancelConnectPortalAtom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAML login success, update clearCookies to false to reuse the SAML session
|
||||||
|
set(clearCookiesAtom, false);
|
||||||
|
|
||||||
|
const credential = {
|
||||||
|
user: authData.username,
|
||||||
|
"prelogin-cookie": authData.prelogin_cookie,
|
||||||
|
"portal-userauthcookie": authData.portal_userauthcookie,
|
||||||
|
};
|
||||||
|
|
||||||
|
await set(portalLoginAtom, credential, prelogin);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const retrySamlAuthAtom = atom(null, async (get) => {
|
||||||
|
const portal = get(portalAddressAtom);
|
||||||
|
const prelogin = await portalService.prelogin(portal);
|
||||||
|
if (prelogin.isSamlAuth) {
|
||||||
|
await authService.emitAuthRequest({
|
||||||
|
samlBinding: prelogin.samlAuthMethod,
|
||||||
|
samlRequest: prelogin.samlRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const portalConfigLoadingAtom = atom(false);
|
||||||
|
const portalLoginAtom = atom(
|
||||||
|
(get) => get(portalConfigLoadingAtom),
|
||||||
|
async (get, set, credential: PortalCredential, prelogin: Prelogin) => {
|
||||||
|
set(statusAtom, "portal-config");
|
||||||
|
set(portalConfigLoadingAtom, true);
|
||||||
|
|
||||||
|
const portalAddress = get(portalAddressAtom);
|
||||||
|
let portalConfig;
|
||||||
|
try {
|
||||||
|
portalConfig = await portalService.fetchConfig(portalAddress, credential);
|
||||||
|
// Ensure the password auth window is closed
|
||||||
|
set(passwordAuthVisibleAtom, false);
|
||||||
|
} finally {
|
||||||
|
set(portalConfigLoadingAtom, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProcessing = get(isProcessingAtom);
|
||||||
|
if (!isProcessing) {
|
||||||
|
console.info("Request cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
|
||||||
|
if (!gateways.length) {
|
||||||
|
throw new Error("No gateway found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
|
||||||
|
throw new Error("Failed to login, please try again");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous selected gateway
|
||||||
|
const previousGateway = get(selectedGatewayAtom);
|
||||||
|
// Update the app data to persist the portal data
|
||||||
|
set(updateAppDataAtom, {
|
||||||
|
type: "PORTAL",
|
||||||
|
payload: {
|
||||||
|
address: portalAddress,
|
||||||
|
gateways: gateways.map(({ name, address }) => ({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
})),
|
||||||
|
cachedCredential: {
|
||||||
|
user: credential.user,
|
||||||
|
passwd: credential.passwd,
|
||||||
|
"portal-userauthcookie": userAuthCookie,
|
||||||
|
"portal-prelogonuserauthcookie": prelogonUserAuthCookie,
|
||||||
|
},
|
||||||
|
selectedGateway: previousGateway,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { region } = prelogin;
|
||||||
|
const { name, address } = portalService.preferredGateway(gateways, {
|
||||||
|
region,
|
||||||
|
previousGateway,
|
||||||
|
});
|
||||||
|
await set(gatewayLoginAtom, address, {
|
||||||
|
user: credential.user,
|
||||||
|
userAuthCookie,
|
||||||
|
prelogonUserAuthCookie,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the app data to persist the gateway data
|
||||||
|
set(updateAppDataAtom, {
|
||||||
|
type: "SELECTED_GATEWAY",
|
||||||
|
payload: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const switchingGatewayAtom = atom(false);
|
||||||
|
export const switchToGatewayAtom = atom(
|
||||||
|
(get) => get(switchingGatewayAtom),
|
||||||
|
async (get, set, gateway: GatewayData) => {
|
||||||
|
set(updateAppDataAtom, {
|
||||||
|
type: "SELECTED_GATEWAY",
|
||||||
|
payload: gateway.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (get(statusAtom) === "connected") {
|
||||||
|
try {
|
||||||
|
set(switchingGatewayAtom, true);
|
||||||
|
await set(disconnectVpnAtom);
|
||||||
|
await set(connectPortalAtom);
|
||||||
|
} finally {
|
||||||
|
set(switchingGatewayAtom, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
94
gpgui/src/atoms/status.ts
Normal file
94
gpgui/src/atoms/status.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { atomWithDefault } from "jotai/utils";
|
||||||
|
import vpnService from "../services/vpnService";
|
||||||
|
import { notifyErrorAtom, notifySuccessAtom } from "./notification";
|
||||||
|
import { selectedGatewayAtom, switchingGatewayAtom } from "./portal";
|
||||||
|
|
||||||
|
export type Status =
|
||||||
|
| "disconnected"
|
||||||
|
| "prelogin"
|
||||||
|
| "authenticating-saml"
|
||||||
|
| "authenticating-password"
|
||||||
|
| "portal-config"
|
||||||
|
| "gateway-login"
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnecting"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
const internalIsOnlineAtom = atomWithDefault(() => vpnService.isOnline());
|
||||||
|
export const isOnlineAtom = atom(
|
||||||
|
(get) => get(internalIsOnlineAtom),
|
||||||
|
async (get, set, update: boolean) => {
|
||||||
|
const isOnline = await get(internalIsOnlineAtom);
|
||||||
|
// Already online, do nothing
|
||||||
|
if (update && update === isOnline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(internalIsOnlineAtom, update);
|
||||||
|
if (update) {
|
||||||
|
set(notifySuccessAtom, "The background service is online");
|
||||||
|
} else {
|
||||||
|
set(notifyErrorAtom, "The background service is offline", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
isOnlineAtom.onMount = (setAtom) => vpnService.onServiceStatusChanged(setAtom);
|
||||||
|
|
||||||
|
const internalStatusReadyAtom = atom(false);
|
||||||
|
export const statusReadyAtom = atom(
|
||||||
|
(get) => get(internalStatusReadyAtom),
|
||||||
|
(get, set, status: Status) => {
|
||||||
|
set(internalStatusReadyAtom, true);
|
||||||
|
set(statusAtom, status);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
statusReadyAtom.onMount = (setAtom) => {
|
||||||
|
vpnService.status().then(setAtom);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusAtom = atom<Status>("disconnected");
|
||||||
|
statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom);
|
||||||
|
|
||||||
|
const statusTextMap: Record<Status, String> = {
|
||||||
|
disconnected: "Not Connected",
|
||||||
|
prelogin: "Portal pre-logging in...",
|
||||||
|
"authenticating-saml": "Authenticating...",
|
||||||
|
"authenticating-password": "Authenticating...",
|
||||||
|
"portal-config": "Retrieving portal config...",
|
||||||
|
"gateway-login": "Logging in to gateway...",
|
||||||
|
connecting: "Connecting...",
|
||||||
|
connected: "Connected",
|
||||||
|
disconnecting: "Disconnecting...",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusTextAtom = atom((get) => {
|
||||||
|
const status = get(statusAtom);
|
||||||
|
const switchingGateway = get(switchingGatewayAtom);
|
||||||
|
|
||||||
|
if (status === "connected") {
|
||||||
|
const selectedGateway = get(selectedGatewayAtom);
|
||||||
|
return selectedGateway
|
||||||
|
? `Gateway: ${selectedGateway}`
|
||||||
|
: statusTextMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switchingGateway) {
|
||||||
|
const selectedGateway = get(selectedGatewayAtom);
|
||||||
|
return `Switching to ${selectedGateway}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusTextMap[status];
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isProcessingAtom = atom((get) => {
|
||||||
|
const status = get(statusAtom);
|
||||||
|
const switchingGateway = get(switchingGatewayAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(status !== "disconnected" && status !== "connected") || switchingGateway
|
||||||
|
);
|
||||||
|
});
|
78
gpgui/src/components/ConnectForm/PasswordAuth.tsx
Normal file
78
gpgui/src/components/ConnectForm/PasswordAuth.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { LoadingButton } from "@mui/lab";
|
||||||
|
import { Box, Button, Drawer, TextField, Typography } from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { FormEvent, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
cancelPasswordAuthAtom,
|
||||||
|
passwordAtom,
|
||||||
|
passwordLoginAtom,
|
||||||
|
passwordPreloginAtom,
|
||||||
|
usernameAtom,
|
||||||
|
} from "../../atoms/portal";
|
||||||
|
|
||||||
|
export default function PasswordAuth() {
|
||||||
|
const [visible, cancelPasswordAuth] = useAtom(cancelPasswordAuthAtom);
|
||||||
|
const { authMessage, labelUsername, labelPassword } =
|
||||||
|
useAtomValue(passwordPreloginAtom);
|
||||||
|
const [username, setUsername] = useAtom(usernameAtom);
|
||||||
|
const [password, setPassword] = useAtom(passwordAtom);
|
||||||
|
const [loading, passwordLogin] = useAtom(passwordLoginAtom);
|
||||||
|
const usernameRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setTimeout(() => {
|
||||||
|
usernameRef.current?.querySelector("input")?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
passwordLogin(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={visible} anchor="bottom" variant="temporary">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Box display="flex" flexDirection="column" gap={1.5} padding={2}>
|
||||||
|
<Typography>{authMessage}</Typography>
|
||||||
|
<TextField
|
||||||
|
ref={usernameRef}
|
||||||
|
label={labelUsername}
|
||||||
|
size="small"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value.trim())}
|
||||||
|
InputProps={{ readOnly: loading }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={labelPassword}
|
||||||
|
size="small"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
InputProps={{ readOnly: loading }}
|
||||||
|
/>
|
||||||
|
<Box display="flex" gap={1.5}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={cancelPasswordAuth}
|
||||||
|
sx={{ flex: 1, textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ flex: 1, textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</LoadingButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
89
gpgui/src/components/ConnectForm/PortalForm.tsx
Normal file
89
gpgui/src/components/ConnectForm/PortalForm.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Button, TextField } from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { ChangeEvent } from "react";
|
||||||
|
import { disconnectVpnAtom } from "../../atoms/gateway";
|
||||||
|
import {
|
||||||
|
cancelConnectPortalAtom,
|
||||||
|
connectPortalAtom,
|
||||||
|
portalAddressAtom,
|
||||||
|
switchingGatewayAtom,
|
||||||
|
} from "../../atoms/portal";
|
||||||
|
import { isOnlineAtom, statusAtom } from "../../atoms/status";
|
||||||
|
|
||||||
|
export default function PortalForm() {
|
||||||
|
const isOnline = useAtomValue(isOnlineAtom);
|
||||||
|
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
|
||||||
|
const status = useAtomValue(statusAtom);
|
||||||
|
const [processing, connectPortal] = useAtom(connectPortalAtom);
|
||||||
|
const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom);
|
||||||
|
const disconnectVpn = useSetAtom(disconnectVpnAtom);
|
||||||
|
const switchingGateway = useAtomValue(switchingGatewayAtom);
|
||||||
|
|
||||||
|
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
let host = e.target.value.trim();
|
||||||
|
if (/^https?:\/\//.test(host)) {
|
||||||
|
try {
|
||||||
|
host = new URL(host).hostname;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
setPortalAddress(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
connectPortal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
label="Portal address"
|
||||||
|
placeholder="Hostname or IP address"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={portalAddress}
|
||||||
|
onChange={handlePortalAddressChange}
|
||||||
|
InputProps={{ readOnly: status !== "disconnected" || switchingGateway }}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
{status === "disconnected" && !switchingGateway && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!isOnline}
|
||||||
|
sx={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(processing || switchingGateway) && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
disabled={
|
||||||
|
status === "authenticating-saml" ||
|
||||||
|
status === "connecting" ||
|
||||||
|
status === "disconnecting" ||
|
||||||
|
switchingGateway
|
||||||
|
}
|
||||||
|
onClick={cancelConnectPortal}
|
||||||
|
sx={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status === "connected" && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
onClick={disconnectVpn}
|
||||||
|
sx={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
11
gpgui/src/components/ConnectForm/index.tsx
Normal file
11
gpgui/src/components/ConnectForm/index.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import PasswordAuth from "./PasswordAuth";
|
||||||
|
import PortalForm from "./PortalForm";
|
||||||
|
|
||||||
|
export default function ConnectForm() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PortalForm />
|
||||||
|
<PasswordAuth />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,104 +0,0 @@
|
|||||||
import GppBadIcon from "@mui/icons-material/GppBad";
|
|
||||||
import VerifiedIcon from "@mui/icons-material/VerifiedUser";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
BoxProps,
|
|
||||||
CircularProgress,
|
|
||||||
Typography,
|
|
||||||
useTheme,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { BeatLoader } from "react-spinners";
|
|
||||||
|
|
||||||
export type Status =
|
|
||||||
| "processing"
|
|
||||||
| "disconnected"
|
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "disconnecting";
|
|
||||||
|
|
||||||
export const statusTextMap: Record<Status, string> = {
|
|
||||||
processing: "Processing...",
|
|
||||||
connected: "Connected",
|
|
||||||
disconnected: "Not Connected",
|
|
||||||
connecting: "Connecting...",
|
|
||||||
disconnecting: "Disconnecting...",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConnectionStatus(
|
|
||||||
props: BoxProps<"div", { status?: Status }>
|
|
||||||
) {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { status = "disconnected" } = props;
|
|
||||||
const { palette } = theme;
|
|
||||||
const colorsMap: Record<Status, string> = {
|
|
||||||
processing: palette.info.main,
|
|
||||||
connected: palette.success.main,
|
|
||||||
disconnected: palette.action.disabled,
|
|
||||||
connecting: palette.info.main,
|
|
||||||
disconnecting: palette.info.main,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pending = ["processing", "connecting", "disconnecting"].includes(status);
|
|
||||||
const connected = status === "connected";
|
|
||||||
const disconnected = status === "disconnected";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box {...props}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
textAlign: "center",
|
|
||||||
position: "relative",
|
|
||||||
width: 150,
|
|
||||||
height: 150,
|
|
||||||
mx: "auto",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress
|
|
||||||
size={150}
|
|
||||||
thickness={1}
|
|
||||||
value={pending ? undefined : 100}
|
|
||||||
variant={pending ? "indeterminate" : "determinate"}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
color: colorsMap[status],
|
|
||||||
"& circle": {
|
|
||||||
fill: colorsMap[status],
|
|
||||||
fillOpacity: pending ? 0.1 : 0.25,
|
|
||||||
transition: "all 0.3s ease",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{pending && <BeatLoader color={colorsMap[status]} />}
|
|
||||||
|
|
||||||
{connected && (
|
|
||||||
<VerifiedIcon
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
fontSize: 80,
|
|
||||||
color: colorsMap[status],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{disconnected && (
|
|
||||||
<GppBadIcon
|
|
||||||
color="disabled"
|
|
||||||
sx={{
|
|
||||||
fontSize: 80,
|
|
||||||
color: colorsMap[status],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography textAlign="center" mt={1.5} variant="subtitle1" paragraph>
|
|
||||||
{statusTextMap[status]}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
103
gpgui/src/components/ConnectionStatus/StatusIcon.tsx
Normal file
103
gpgui/src/components/ConnectionStatus/StatusIcon.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { GppBad, VerifiedUser as VerifiedIcon } from "@mui/icons-material";
|
||||||
|
import { Box, CircularProgress, styled, useTheme } from "@mui/material";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { BeatLoader } from "react-spinners";
|
||||||
|
import { isProcessingAtom, statusAtom } from "../../atoms/status";
|
||||||
|
|
||||||
|
function useStatusColor() {
|
||||||
|
const status = useAtomValue(statusAtom);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
if (status === "disconnected") {
|
||||||
|
return theme.palette.action.disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "connected") {
|
||||||
|
return theme.palette.success.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return theme.palette.error.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme.palette.info.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackgroundIcon() {
|
||||||
|
const color = useStatusColor();
|
||||||
|
const isProcessing = useAtomValue(isProcessingAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CircularProgress
|
||||||
|
size={150}
|
||||||
|
thickness={1}
|
||||||
|
value={isProcessing ? undefined : 100}
|
||||||
|
variant={isProcessing ? "indeterminate" : "determinate"}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
color,
|
||||||
|
"& circle": {
|
||||||
|
fill: color,
|
||||||
|
fillOpacity: isProcessing ? 0.1 : 0.25,
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DisconnectedIcon = styled(GppBad)(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
fontSize: 90,
|
||||||
|
color: theme.palette.action.disabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function ProcessingIcon() {
|
||||||
|
const theme = useTheme();
|
||||||
|
return <BeatLoader color={theme.palette.info.main} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedIcon = styled(VerifiedIcon)(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
fontSize: 80,
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const IconContainer = styled(Box)(({ theme }) =>
|
||||||
|
theme.unstable_sx({
|
||||||
|
position: "relative",
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
textAlign: "center",
|
||||||
|
mx: "auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function InnerStatusIcon() {
|
||||||
|
const status = useAtomValue(statusAtom);
|
||||||
|
const isProcessing = useAtomValue(isProcessingAtom);
|
||||||
|
|
||||||
|
if (isProcessing) {
|
||||||
|
return <ProcessingIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "connected") {
|
||||||
|
return <ConnectedIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DisconnectedIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusIcon() {
|
||||||
|
return (
|
||||||
|
<IconContainer>
|
||||||
|
<BackgroundIcon />
|
||||||
|
<InnerStatusIcon />
|
||||||
|
</IconContainer>
|
||||||
|
);
|
||||||
|
}
|
23
gpgui/src/components/ConnectionStatus/StatusText.tsx
Normal file
23
gpgui/src/components/ConnectionStatus/StatusText.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Typography } from "@mui/material";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { statusTextAtom } from "../../atoms/status";
|
||||||
|
|
||||||
|
export default function StatusText() {
|
||||||
|
const statusText = useAtomValue(statusTextAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
textAlign="center"
|
||||||
|
mt={1.5}
|
||||||
|
variant="subtitle1"
|
||||||
|
paragraph
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
12
gpgui/src/components/ConnectionStatus/index.tsx
Normal file
12
gpgui/src/components/ConnectionStatus/index.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import StatusIcon from "./StatusIcon";
|
||||||
|
import StatusText from "./StatusText";
|
||||||
|
|
||||||
|
export default function ConnectionStatus() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<StatusIcon />
|
||||||
|
<StatusText />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
43
gpgui/src/components/Feedback/index.tsx
Normal file
43
gpgui/src/components/Feedback/index.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { BugReport, Favorite } from "@mui/icons-material";
|
||||||
|
import { Chip, ChipProps, Stack } from "@mui/material";
|
||||||
|
import { red } from "@mui/material/colors";
|
||||||
|
|
||||||
|
const LinkChip = (props: ChipProps<"a">) => (
|
||||||
|
<Chip
|
||||||
|
component="a"
|
||||||
|
target="_blank"
|
||||||
|
clickable
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Feedback() {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" justifyContent="space-evenly" mt={1}>
|
||||||
|
<LinkChip
|
||||||
|
avatar={<BugReport />}
|
||||||
|
label="Feedback"
|
||||||
|
href="https://github.com/yuezk/GlobalProtect-openconnect/issues"
|
||||||
|
/>
|
||||||
|
<LinkChip
|
||||||
|
avatar={<Favorite />}
|
||||||
|
label="Donate"
|
||||||
|
href="https://www.buymeacoffee.com/yuezk"
|
||||||
|
sx={{
|
||||||
|
"& .MuiSvgIcon-root": {
|
||||||
|
color: red[300],
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
".MuiSvgIcon-root": {
|
||||||
|
color: red[500],
|
||||||
|
transform: "scale(1.1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
58
gpgui/src/components/GatewaySwitcher/index.tsx
Normal file
58
gpgui/src/components/GatewaySwitcher/index.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Check } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { gatewaySwitcherVisibleAtom } from "../../atoms/gateway";
|
||||||
|
import {
|
||||||
|
GatewayData,
|
||||||
|
portalGatewaysAtom,
|
||||||
|
selectedGatewayAtom,
|
||||||
|
switchToGatewayAtom,
|
||||||
|
} from "../../atoms/portal";
|
||||||
|
|
||||||
|
export default function GatewaySwitcher() {
|
||||||
|
const [visible, setGatewaySwitcherVisible] = useAtom(
|
||||||
|
gatewaySwitcherVisibleAtom
|
||||||
|
);
|
||||||
|
const gateways = useAtomValue(portalGatewaysAtom);
|
||||||
|
const selectedGateway = useAtomValue(selectedGatewayAtom);
|
||||||
|
const switchToGateway = useSetAtom(switchToGatewayAtom);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setGatewaySwitcherVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = (gateway: GatewayData) => () => {
|
||||||
|
setGatewaySwitcherVisible(false);
|
||||||
|
if (gateway.name !== selectedGateway) {
|
||||||
|
switchToGateway(gateway);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor="bottom" open={visible} onClose={handleClose}>
|
||||||
|
<MenuList
|
||||||
|
sx={{
|
||||||
|
maxHeight: 320,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!gateways.length && <MenuItem disabled>No gateways found</MenuItem>}
|
||||||
|
{gateways.map(({ name, address }) => (
|
||||||
|
<MenuItem key={name} onClick={handleMenuClick({ name, address })}>
|
||||||
|
{selectedGateway === name && (
|
||||||
|
<ListItemIcon>
|
||||||
|
<Check />
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
|
<ListItemText inset={selectedGateway !== name}>{name}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
111
gpgui/src/components/MainMenu/index.tsx
Normal file
111
gpgui/src/components/MainMenu/index.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
ExitToApp,
|
||||||
|
GitHub,
|
||||||
|
LockReset,
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Settings,
|
||||||
|
VpnLock,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Box, Divider, IconButton, Menu, MenuItem } from "@mui/material";
|
||||||
|
import { alpha, styled } from "@mui/material/styles";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { openGatewaySwitcherAtom } from "../../atoms/gateway";
|
||||||
|
import { quitAtom, resetAtom } from "../../atoms/menu";
|
||||||
|
import { isProcessingAtom, statusAtom } from "../../atoms/status";
|
||||||
|
|
||||||
|
const MenuContainer = styled(Box)(({ theme }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
left: theme.spacing(1),
|
||||||
|
top: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)(({ theme }) => ({
|
||||||
|
"& .MuiPaper-root": {
|
||||||
|
borderRadius: 6,
|
||||||
|
minWidth: 180,
|
||||||
|
"& .MuiMenu-list": {
|
||||||
|
padding: "4px 0",
|
||||||
|
},
|
||||||
|
"& .MuiMenuItem-root": {
|
||||||
|
minHeight: "auto",
|
||||||
|
"& .MuiSvgIcon-root": {
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginRight: theme.spacing(1.5),
|
||||||
|
},
|
||||||
|
"&:active": {
|
||||||
|
backgroundColor: alpha(
|
||||||
|
theme.palette.primary.main,
|
||||||
|
theme.palette.action.selectedOpacity
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function MainMenu() {
|
||||||
|
const isProcessing = useAtomValue(isProcessingAtom);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom);
|
||||||
|
const status = useAtomValue(statusAtom);
|
||||||
|
const reset = useSetAtom(resetAtom);
|
||||||
|
const quit = useSetAtom(quitAtom);
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuContainer>
|
||||||
|
<IconButton onClick={handleClick} disabled={isProcessing}>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<StyledMenu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={openGatewaySwitcher} disableRipple>
|
||||||
|
<VpnLock />
|
||||||
|
Switch Gateway
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleClose} disableRipple>
|
||||||
|
<Settings />
|
||||||
|
Settings
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={reset}
|
||||||
|
disableRipple
|
||||||
|
disabled={status !== "disconnected"}
|
||||||
|
>
|
||||||
|
<LockReset />
|
||||||
|
Reset
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={quit} disableRipple>
|
||||||
|
<ExitToApp />
|
||||||
|
Quit
|
||||||
|
</MenuItem>
|
||||||
|
</StyledMenu>
|
||||||
|
</MenuContainer>
|
||||||
|
<IconButton
|
||||||
|
href="https://github.com/yuezk/GlobalProtect-openconnect"
|
||||||
|
target="_blank"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
right: (theme) => theme.spacing(1),
|
||||||
|
top: (theme) => theme.spacing(1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GitHub />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertColor,
|
|
||||||
AlertTitle,
|
|
||||||
Slide,
|
|
||||||
SlideProps,
|
|
||||||
Snackbar,
|
|
||||||
SnackbarCloseReason,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
type TransitionProps = Omit<SlideProps, "direction">;
|
|
||||||
|
|
||||||
function TransitionDown(props: TransitionProps) {
|
|
||||||
return <Slide {...props} direction="down" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationType = AlertColor;
|
|
||||||
export type NotificationConfig = {
|
|
||||||
open: boolean;
|
|
||||||
message: string;
|
|
||||||
title?: string;
|
|
||||||
type?: NotificationType;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NotificationProps = {
|
|
||||||
onClose: () => void;
|
|
||||||
} & NotificationConfig;
|
|
||||||
|
|
||||||
export default function Notification(props: NotificationProps) {
|
|
||||||
const { open, message, title, type = "info", onClose } = props;
|
|
||||||
|
|
||||||
function handleClose(
|
|
||||||
_: React.SyntheticEvent | Event,
|
|
||||||
reason?: SnackbarCloseReason
|
|
||||||
) {
|
|
||||||
if (reason === "clickaway") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
open={open}
|
|
||||||
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
|
||||||
autoHideDuration={5000}
|
|
||||||
TransitionComponent={TransitionDown}
|
|
||||||
onClose={handleClose}
|
|
||||||
sx={{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
severity={type}
|
|
||||||
icon={false}
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
borderRadius: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title && <AlertTitle>{title}</AlertTitle>}
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
);
|
|
||||||
}
|
|
50
gpgui/src/components/Notification/index.tsx
Normal file
50
gpgui/src/components/Notification/index.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Alert, AlertTitle, Slide, SlideProps, Snackbar } from "@mui/material";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
closeNotificationAtom,
|
||||||
|
notificationConfigAtom,
|
||||||
|
} from "../../atoms/notification";
|
||||||
|
|
||||||
|
type TransitionProps = Omit<SlideProps, "direction">;
|
||||||
|
function TransitionDown(props: TransitionProps) {
|
||||||
|
return <Slide {...props} direction="down" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notification() {
|
||||||
|
const { title, message, severity, duration } = useAtomValue(
|
||||||
|
notificationConfigAtom
|
||||||
|
);
|
||||||
|
const [visible, closeNotification] = useAtom(closeNotificationAtom);
|
||||||
|
const handleClose = () => {
|
||||||
|
if (duration) {
|
||||||
|
closeNotification();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
open={visible}
|
||||||
|
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||||
|
autoHideDuration={duration}
|
||||||
|
TransitionComponent={TransitionDown}
|
||||||
|
onClose={handleClose}
|
||||||
|
sx={{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={severity}
|
||||||
|
icon={false}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && <AlertTitle>{title}</AlertTitle>}
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
);
|
||||||
|
}
|
@ -1,120 +0,0 @@
|
|||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
|
||||||
import { Box, Button, Drawer, TextField, Typography } from "@mui/material";
|
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
|
||||||
import { Maybe } from "../types";
|
|
||||||
|
|
||||||
export type PasswordAuthData = {
|
|
||||||
labelUsername: string;
|
|
||||||
labelPassword: string;
|
|
||||||
authMessage: Maybe<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Credentials = {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LoginCallback = (params: Credentials) => void;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
authData: PasswordAuthData | undefined;
|
|
||||||
authenticating: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onLogin: LoginCallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuthFormProps = {
|
|
||||||
authenticating: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSubmit: LoginCallback;
|
|
||||||
} & PasswordAuthData;
|
|
||||||
|
|
||||||
function AuthForm(props: AuthFormProps) {
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const inputRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.querySelector("input")?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
authenticating,
|
|
||||||
authMessage,
|
|
||||||
labelUsername,
|
|
||||||
labelPassword,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (username.trim() === "" || password === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit({ username, password });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Box display="flex" flexDirection="column" gap={1.5} padding={2}>
|
|
||||||
<Typography>{authMessage}</Typography>
|
|
||||||
<TextField
|
|
||||||
ref={inputRef}
|
|
||||||
label={labelUsername}
|
|
||||||
size="small"
|
|
||||||
autoFocus
|
|
||||||
value={username}
|
|
||||||
InputProps={{ readOnly: authenticating }}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={labelPassword}
|
|
||||||
size="small"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
InputProps={{ readOnly: authenticating }}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Box display="flex" gap={1.5}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ flex: 1, textTransform: "none" }}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<LoadingButton
|
|
||||||
loading={authenticating}
|
|
||||||
variant="contained"
|
|
||||||
sx={{ flex: 1, textTransform: "none" }}
|
|
||||||
type="submit"
|
|
||||||
disabled={authenticating}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</LoadingButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PasswordAuth(props: Props) {
|
|
||||||
const { open, authData, authenticating, onCancel, onLogin } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer anchor="bottom" variant="temporary" open={open}>
|
|
||||||
{authData && (
|
|
||||||
<AuthForm
|
|
||||||
{...authData}
|
|
||||||
authenticating={authenticating}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={onLogin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
51
gpgui/src/services/authService.ts
Normal file
51
gpgui/src/services/authService.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { emit, listen } from "@tauri-apps/api/event";
|
||||||
|
import invokeCommand from "../utils/invokeCommand";
|
||||||
|
|
||||||
|
export type AuthData = {
|
||||||
|
username: string;
|
||||||
|
prelogin_cookie?: string;
|
||||||
|
portal_userauthcookie?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
private authErrorCallback: (() => void) | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init() {
|
||||||
|
await listen("auth-error", (evt) => {
|
||||||
|
console.error("auth-error", evt);
|
||||||
|
this.authErrorCallback?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onAuthError(callback: () => void) {
|
||||||
|
this.authErrorCallback = callback;
|
||||||
|
return () => {
|
||||||
|
this.authErrorCallback = undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// binding: "POST" | "REDIRECT"
|
||||||
|
async samlLogin(binding: string, request: string, clearCookies: boolean) {
|
||||||
|
return invokeCommand<AuthData>("saml_login", {
|
||||||
|
binding,
|
||||||
|
request,
|
||||||
|
clearCookies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async emitAuthRequest({
|
||||||
|
samlBinding,
|
||||||
|
samlRequest,
|
||||||
|
}: {
|
||||||
|
samlBinding: string;
|
||||||
|
samlRequest: string;
|
||||||
|
}) {
|
||||||
|
await emit("auth-request", { samlBinding, samlRequest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AuthService();
|
@ -1,23 +1,39 @@
|
|||||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||||
import { Maybe } from "../types";
|
|
||||||
import { parseXml } from "../utils/parseXml";
|
import { parseXml } from "../utils/parseXml";
|
||||||
import { Gateway } from "./types";
|
|
||||||
|
|
||||||
type LoginParams = {
|
type LoginParams = {
|
||||||
gateway: Gateway;
|
user: string;
|
||||||
username: string;
|
passwd?: string | null;
|
||||||
password: string;
|
userAuthCookie?: string | null;
|
||||||
userAuthCookie: Maybe<string>;
|
prelogonUserAuthCookie?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
class GatewayService {
|
class GatewayService {
|
||||||
async login(params: LoginParams) {
|
async login(gateway: string, params: LoginParams) {
|
||||||
const { gateway, username, password, userAuthCookie } = params;
|
const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params;
|
||||||
if (!gateway.address) {
|
if (!gateway) {
|
||||||
throw new Error("Gateway address is required");
|
throw new Error("Gateway address is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`;
|
const loginUrl = `https://${gateway}/ssl-vpn/login.esp`;
|
||||||
|
const body = Body.form({
|
||||||
|
prot: "https:",
|
||||||
|
inputStr: "",
|
||||||
|
jnlpReady: "jnlpReady",
|
||||||
|
computer: "Linux", // TODO
|
||||||
|
ok: "Login",
|
||||||
|
direct: "yes",
|
||||||
|
"ipv6-support": "yes",
|
||||||
|
clientVer: "4100",
|
||||||
|
clientos: "Linux",
|
||||||
|
"os-version": "Linux",
|
||||||
|
server: gateway,
|
||||||
|
user,
|
||||||
|
passwd: passwd || "",
|
||||||
|
"prelogin-cookie": "",
|
||||||
|
"portal-userauthcookie": userAuthCookie || "",
|
||||||
|
"portal-prelogonuserauthcookie": prelogonUserAuthCookie || "",
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch<string>(loginUrl, {
|
const response = await fetch<string>(loginUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -25,24 +41,7 @@ class GatewayService {
|
|||||||
"User-Agent": "PAN GlobalProtect",
|
"User-Agent": "PAN GlobalProtect",
|
||||||
},
|
},
|
||||||
responseType: ResponseType.Text,
|
responseType: ResponseType.Text,
|
||||||
body: Body.form({
|
body,
|
||||||
prot: "https:",
|
|
||||||
inputStr: "",
|
|
||||||
jnlpReady: "jnlpReady",
|
|
||||||
computer: "Linux", // TODO
|
|
||||||
ok: "Login",
|
|
||||||
direct: "yes",
|
|
||||||
"ipv6-support": "yes",
|
|
||||||
clientVer: "4100",
|
|
||||||
clientos: "Linux",
|
|
||||||
"os-version": "Linux",
|
|
||||||
server: gateway.address,
|
|
||||||
user: username,
|
|
||||||
passwd: password,
|
|
||||||
"portal-userauthcookie": userAuthCookie ?? "",
|
|
||||||
"portal-prelogonuserauthcookie": "",
|
|
||||||
"prelogin-cookie": "",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -1,119 +1,148 @@
|
|||||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||||
import { Maybe, MaybeProperties } from "../types";
|
|
||||||
import { parseXml } from "../utils/parseXml";
|
import { parseXml } from "../utils/parseXml";
|
||||||
import { Gateway } from "./types";
|
import { Gateway } from "./types";
|
||||||
|
|
||||||
type SamlPreloginResponse = {
|
export type SamlPrelogin = {
|
||||||
|
isSamlAuth: true;
|
||||||
samlAuthMethod: string;
|
samlAuthMethod: string;
|
||||||
samlAuthRequest: string;
|
samlRequest: string;
|
||||||
};
|
|
||||||
|
|
||||||
type PasswordPreloginResponse = {
|
|
||||||
labelUsername: string;
|
|
||||||
labelPassword: string;
|
|
||||||
authMessage: Maybe<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Region = {
|
|
||||||
region: string;
|
region: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreloginResponse = MaybeProperties<
|
export type PasswordPrelogin = {
|
||||||
SamlPreloginResponse & PasswordPreloginResponse & Region
|
isSamlAuth: false;
|
||||||
>;
|
authMessage: string;
|
||||||
|
labelUsername: string;
|
||||||
|
labelPassword: string;
|
||||||
|
region: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConfigResponse = {
|
export type Prelogin = SamlPrelogin | PasswordPrelogin;
|
||||||
userAuthCookie: Maybe<string>;
|
|
||||||
prelogonUserAuthCookie: Maybe<string>;
|
export type PortalConfig = {
|
||||||
preferredGateway: Gateway;
|
userAuthCookie: string;
|
||||||
|
prelogonUserAuthCookie: string;
|
||||||
gateways: Gateway[];
|
gateways: Gateway[];
|
||||||
};
|
};
|
||||||
|
|
||||||
class PortalService {
|
export type PortalCredential = {
|
||||||
async prelogin(portal: string) {
|
user: string;
|
||||||
const preloginUrl = `https://${portal}/global-protect/prelogin.esp`;
|
passwd?: string; // for password auth
|
||||||
|
"prelogin-cookie"?: string; // for saml auth
|
||||||
|
"portal-userauthcookie"?: string; // cached cookie from previous portal config
|
||||||
|
"portal-prelogonuserauthcookie"?: string; // cached cookie from previous portal config
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch<string>(preloginUrl, {
|
class PortalService {
|
||||||
method: "GET",
|
async prelogin(portal: string): Promise<Prelogin> {
|
||||||
responseType: ResponseType.Text,
|
const preloginUrl = `https://${portal}/global-protect/prelogin.esp`;
|
||||||
query: {
|
try {
|
||||||
tmp: "tmp",
|
const response = await fetch<string>(preloginUrl, {
|
||||||
"kerberos-support": "yes",
|
method: "POST",
|
||||||
"ipv6-support": "yes",
|
headers: {
|
||||||
clientVer: "4100",
|
"User-Agent": "PAN GlobalProtect",
|
||||||
clientos: "Linux",
|
},
|
||||||
},
|
responseType: ResponseType.Text,
|
||||||
|
query: {
|
||||||
|
"kerberos-support": "yes",
|
||||||
|
},
|
||||||
|
body: Body.form({
|
||||||
|
tmp: "tmp",
|
||||||
|
clientVer: "4100",
|
||||||
|
clientos: "Linux",
|
||||||
|
"os-version": "Linux",
|
||||||
|
"ipv6-support": "yes",
|
||||||
|
"default-browser": "0",
|
||||||
|
"cas-support": "yes",
|
||||||
|
// "host-id": "TODO, mac address?",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to prelogin: ${response.status}`);
|
||||||
|
}
|
||||||
|
return this.parsePrelogin(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to prelogin: Network error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrelogin(response: string): Prelogin {
|
||||||
|
const doc = parseXml(response);
|
||||||
|
const status = doc.text("status").toUpperCase();
|
||||||
|
|
||||||
|
if (status !== "SUCCESS") {
|
||||||
|
const message = doc.text("msg") || "Unknown error";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const samlAuthMethod = doc.text("saml-auth-method").toUpperCase();
|
||||||
|
const samlRequest = doc.text("saml-request");
|
||||||
|
const labelUsername = doc.text("username-label");
|
||||||
|
const labelPassword = doc.text("password-label");
|
||||||
|
const authMessage = doc.text("authentication-message");
|
||||||
|
const region = doc.text("region");
|
||||||
|
|
||||||
|
if (samlAuthMethod && samlRequest) {
|
||||||
|
return {
|
||||||
|
isSamlAuth: true,
|
||||||
|
samlAuthMethod,
|
||||||
|
samlRequest: atob(samlRequest),
|
||||||
|
region,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelUsername && labelPassword) {
|
||||||
|
return {
|
||||||
|
isSamlAuth: false,
|
||||||
|
authMessage,
|
||||||
|
labelUsername,
|
||||||
|
labelPassword,
|
||||||
|
region,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unknown prelogin response");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchConfig(portal: string, params: PortalCredential) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
passwd,
|
||||||
|
"prelogin-cookie": preloginCookie,
|
||||||
|
"portal-userauthcookie": portalUserAuthCookie,
|
||||||
|
"portal-prelogonuserauthcookie": portalPrelogonUserAuthCookie,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const configUrl = `https://${portal}/global-protect/getconfig.esp`;
|
||||||
|
const body = Body.form({
|
||||||
|
prot: "https:",
|
||||||
|
inputStr: "",
|
||||||
|
jnlpReady: "jnlpReady",
|
||||||
|
computer: "Linux", // TODO
|
||||||
|
clientos: "Linux",
|
||||||
|
ok: "Login",
|
||||||
|
direct: "yes",
|
||||||
|
clientVer: "4100",
|
||||||
|
"os-version": "Linux",
|
||||||
|
clientgpversion: "6.0.1-19",
|
||||||
|
"ipv6-support": "yes",
|
||||||
|
server: portal,
|
||||||
|
host: portal,
|
||||||
|
user,
|
||||||
|
passwd: passwd || "",
|
||||||
|
"prelogin-cookie": preloginCookie || "",
|
||||||
|
"portal-userauthcookie": portalUserAuthCookie || "",
|
||||||
|
"portal-prelogonuserauthcookie": portalPrelogonUserAuthCookie || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to connect to portal: ${response.status}`);
|
|
||||||
}
|
|
||||||
return this.parsePreloginResponse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parsePreloginResponse(response: string): PreloginResponse {
|
|
||||||
const doc = parseXml(response);
|
|
||||||
|
|
||||||
return {
|
|
||||||
samlAuthMethod: doc.text("saml-auth-method"),
|
|
||||||
samlAuthRequest: doc.text("saml-auth-request"),
|
|
||||||
labelUsername: doc.text("username-label"),
|
|
||||||
labelPassword: doc.text("password-label"),
|
|
||||||
authMessage: doc.text("authentication-message"),
|
|
||||||
region: doc.text("region"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
isSamlAuth(response: PreloginResponse): response is SamlPreloginResponse {
|
|
||||||
if (response.samlAuthMethod && response.samlAuthRequest) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPasswordAuth(
|
|
||||||
response: PreloginResponse
|
|
||||||
): response is PasswordPreloginResponse {
|
|
||||||
if (response.labelUsername && response.labelPassword) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchConfig({
|
|
||||||
portal,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
}: {
|
|
||||||
portal: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}) {
|
|
||||||
const configUrl = `https://${portal}/global-protect/getconfig.esp`;
|
|
||||||
const response = await fetch<string>(configUrl, {
|
const response = await fetch<string>(configUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "PAN GlobalProtect",
|
"User-Agent": "PAN GlobalProtect",
|
||||||
},
|
},
|
||||||
responseType: ResponseType.Text,
|
responseType: ResponseType.Text,
|
||||||
body: Body.form({
|
body,
|
||||||
prot: "https:",
|
|
||||||
inputStr: "",
|
|
||||||
jnlpReady: "jnlpReady",
|
|
||||||
computer: "Linux", // TODO
|
|
||||||
clientos: "Linux",
|
|
||||||
ok: "Login",
|
|
||||||
direct: "yes",
|
|
||||||
clientVer: "4100",
|
|
||||||
"os-version": "Linux",
|
|
||||||
"ipv6-support": "yes",
|
|
||||||
server: portal,
|
|
||||||
user: username,
|
|
||||||
passwd: password,
|
|
||||||
"portal-userauthcookie": "",
|
|
||||||
"portal-prelogonuserauthcookie": "",
|
|
||||||
"prelogin-cookie": "",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -124,7 +153,9 @@ class PortalService {
|
|||||||
return this.parsePortalConfigResponse(response.data);
|
return this.parsePortalConfigResponse(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePortalConfigResponse(response: string): ConfigResponse {
|
private parsePortalConfigResponse(response: string): PortalConfig {
|
||||||
|
// console.log(response);
|
||||||
|
|
||||||
const result = parseXml(response);
|
const result = parseXml(response);
|
||||||
const gateways = result.all("gateways list > entry").map((entry) => {
|
const gateways = result.all("gateways list > entry").map((entry) => {
|
||||||
const address = entry.attr("name");
|
const address = entry.attr("name");
|
||||||
@ -134,13 +165,13 @@ class PortalService {
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
address,
|
address,
|
||||||
priority: priority ? parseInt(priority, 10) : undefined,
|
priority: priority ? parseInt(priority, 10) : Infinity,
|
||||||
priorityRules: entry.all("priority-rule > entry").map((entry) => {
|
priorityRules: entry.all("priority-rule > entry").map((entry) => {
|
||||||
const name = entry.attr("name");
|
const name = entry.attr("name");
|
||||||
const priority = entry.text("priority");
|
const priority = entry.text("priority");
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
priority: priority ? parseInt(priority, 10) : undefined,
|
priority: priority ? parseInt(priority, 10) : Infinity,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@ -149,10 +180,45 @@ class PortalService {
|
|||||||
return {
|
return {
|
||||||
userAuthCookie: result.text("portal-userauthcookie"),
|
userAuthCookie: result.text("portal-userauthcookie"),
|
||||||
prelogonUserAuthCookie: result.text("portal-prelogonuserauthcookie"),
|
prelogonUserAuthCookie: result.text("portal-prelogonuserauthcookie"),
|
||||||
preferredGateway: gateways[0],
|
|
||||||
gateways,
|
gateways,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preferredGateway(
|
||||||
|
gateways: Gateway[],
|
||||||
|
{ region, previousGateway }: { region: string; previousGateway?: string }
|
||||||
|
) {
|
||||||
|
for (const gateway of gateways) {
|
||||||
|
if (gateway.name === previousGateway) {
|
||||||
|
return gateway;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultGateway = gateways[0];
|
||||||
|
for (const gateway of gateways) {
|
||||||
|
if (gateway.priority < defaultGateway.priority) {
|
||||||
|
defaultGateway = gateway;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
return defaultGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferredGateway = defaultGateway;
|
||||||
|
let currentPriority = Infinity;
|
||||||
|
for (const gateway of gateways) {
|
||||||
|
const priorityRule = gateway.priorityRules.find(
|
||||||
|
({ name }) => name === region
|
||||||
|
);
|
||||||
|
|
||||||
|
if (priorityRule && priorityRule.priority < currentPriority) {
|
||||||
|
preferredGateway = gateway;
|
||||||
|
currentPriority = priorityRule.priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return preferredGateway;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new PortalService();
|
export default new PortalService();
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
import { Maybe } from '../types';
|
|
||||||
|
|
||||||
type PriorityRule = {
|
type PriorityRule = {
|
||||||
name: Maybe<string>;
|
name: string;
|
||||||
priority: Maybe<number>;
|
priority: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Gateway = {
|
export type Gateway = {
|
||||||
name: Maybe<string>;
|
name: string;
|
||||||
address: Maybe<string>;
|
address: string;
|
||||||
priorityRules: PriorityRule[];
|
priorityRules: PriorityRule[];
|
||||||
priority: Maybe<number>;
|
priority: number;
|
||||||
};
|
};
|
||||||
|
@ -1,71 +1,100 @@
|
|||||||
import { invoke } from "@tauri-apps/api";
|
import { Event, listen } from "@tauri-apps/api/event";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import invokeCommand from "../utils/invokeCommand";
|
||||||
|
|
||||||
type Status = "disconnected" | "connecting" | "connected" | "disconnecting";
|
type VpnStatus = "disconnected" | "connecting" | "connected" | "disconnecting";
|
||||||
type StatusCallback = (status: Status) => void;
|
type VpnStatusCallback = (status: VpnStatus) => void;
|
||||||
type StatusEvent = {
|
type VpnStatusPayload = {
|
||||||
payload: {
|
status: VpnStatus;
|
||||||
status: Status;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ServiceStatusCallback = (status: boolean) => void;
|
||||||
|
|
||||||
class VpnService {
|
class VpnService {
|
||||||
private _status: Status = "disconnected";
|
private _isOnline?: boolean;
|
||||||
private statusCallbacks: StatusCallback[] = [];
|
private _status?: VpnStatus;
|
||||||
|
private statusCallbacks: VpnStatusCallback[] = [];
|
||||||
|
private serviceStatusCallbacks: ServiceStatusCallback[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async init() {
|
private async init() {
|
||||||
await listen("vpn-status-received", (event: StatusEvent) => {
|
await listen("service-status-changed", (event: Event<boolean>) => {
|
||||||
console.log("vpn-status-received", event.payload);
|
this.setIsOnline(event.payload);
|
||||||
this.setStatus(event.payload.status);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = await this.status();
|
await listen("vpn-status-received", (event: Event<VpnStatusPayload>) => {
|
||||||
this.setStatus(status);
|
this.setStatus(event.payload.status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(status: Status) {
|
async isOnline() {
|
||||||
if (this._status != status) {
|
try {
|
||||||
this._status = status;
|
const isOnline = await invokeCommand<boolean>("service_online");
|
||||||
this.fireStatusCallbacks();
|
this.setIsOnline(isOnline);
|
||||||
|
return isOnline;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async status(): Promise<Status> {
|
private setIsOnline(isOnline: boolean) {
|
||||||
return this.invokeCommand<Status>("vpn_status");
|
if (this._isOnline !== isOnline) {
|
||||||
|
this._isOnline = isOnline;
|
||||||
|
this.serviceStatusCallbacks.forEach((cb) => cb(isOnline));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: VpnStatus) {
|
||||||
|
if (this._status !== status) {
|
||||||
|
this._status = status;
|
||||||
|
this.statusCallbacks.forEach((cb) => cb(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(): Promise<VpnStatus> {
|
||||||
|
try {
|
||||||
|
const status = await invokeCommand<VpnStatus>("vpn_status");
|
||||||
|
this._status = status;
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
return "disconnected";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(server: string, cookie: string) {
|
async connect(server: string, cookie: string) {
|
||||||
return this.invokeCommand("vpn_connect", { server, cookie });
|
return invokeCommand("vpn_connect", { server, cookie });
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
return this.invokeCommand("vpn_disconnect");
|
return invokeCommand("vpn_disconnect");
|
||||||
}
|
}
|
||||||
|
|
||||||
onStatusChanged(callback: StatusCallback) {
|
onVpnStatusChanged(callback: VpnStatusCallback) {
|
||||||
this.statusCallbacks.push(callback);
|
this.statusCallbacks.push(callback);
|
||||||
callback(this._status);
|
if (typeof this._status === "string") {
|
||||||
return () => this.removeStatusCallback(callback);
|
callback(this._status);
|
||||||
|
}
|
||||||
|
return () => this.removeVpnStatusCallback(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fireStatusCallbacks() {
|
onServiceStatusChanged(callback: ServiceStatusCallback) {
|
||||||
this.statusCallbacks.forEach((cb) => cb(this._status));
|
this.serviceStatusCallbacks.push(callback);
|
||||||
|
if (typeof this._isOnline === "boolean") {
|
||||||
|
callback(this._isOnline);
|
||||||
|
}
|
||||||
|
return () => this.removeServiceStatusCallback(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeStatusCallback(callback: StatusCallback) {
|
private removeVpnStatusCallback(callback: VpnStatusCallback) {
|
||||||
this.statusCallbacks = this.statusCallbacks.filter((cb) => cb !== callback);
|
this.statusCallbacks = this.statusCallbacks.filter((cb) => cb !== callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async invokeCommand<T>(command: string, args?: any) {
|
private removeServiceStatusCallback(callback: ServiceStatusCallback) {
|
||||||
try {
|
this.serviceStatusCallbacks = this.serviceStatusCallbacks.filter(
|
||||||
return await invoke<T>(command, args);
|
(cb) => cb !== callback
|
||||||
} catch (err: any) {
|
);
|
||||||
throw new Error(err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
export type Maybe<T> = T | null | undefined;
|
|
||||||
|
|
||||||
export type MaybeProperties<T> = {
|
|
||||||
[P in keyof T]?: Maybe<T[P]>;
|
|
||||||
};
|
|
10
gpgui/src/utils/invokeCommand.ts
Normal file
10
gpgui/src/utils/invokeCommand.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api";
|
||||||
|
|
||||||
|
export default async function invokeCommand<T>(command: string, args?: any) {
|
||||||
|
try {
|
||||||
|
return await invoke<T>(command, args);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message ?? "Unknown error";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ edition = "2021"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
gpcommon = { path = "../gpcommon" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
env_logger = "0.10"
|
env_logger = "0.10"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
@ -15,4 +15,4 @@ log = "0.4"
|
|||||||
# procfs = "0.15"
|
# procfs = "0.15"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
common = { path = "../common" }
|
gpcommon = { path = "../gpcommon" }
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use common::sha256_digest;
|
use gpcommon::sha256_digest;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
include!(concat!(env!("OUT_DIR"), "/client_hash.rs"));
|
include!(concat!(env!("OUT_DIR"), "/client_hash.rs"));
|
||||||
|
|
||||||
// use aes_gcm::{aead::OsRng, Aes256Gcm, KeyInit};
|
// use aes_gcm::{aead::OsRng, Aes256Gcm, KeyInit};
|
||||||
use common::{server, SOCKET_PATH};
|
use gpcommon::{server, SOCKET_PATH};
|
||||||
use env_logger::Env;
|
use env_logger::Env;
|
||||||
use log::error;
|
use log::error;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user