refactor: encrypt the sensitive data

This commit is contained in:
Kevin Yue
2023-07-22 07:33:53 +08:00
parent bf96a88e21
commit 601f422863
40 changed files with 1274 additions and 275 deletions

View File

@@ -23,8 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spinners": "^0.13.8",
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1"
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log#v1"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",

28
gpgui/pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.1'
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
@@ -48,11 +48,8 @@ dependencies:
specifier: ^0.13.8
version: 0.13.8(react-dom@18.2.0)(react@18.2.0)
tauri-plugin-log-api:
specifier: github:tauri-apps/tauri-plugin-log
version: github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9
tauri-plugin-store-api:
specifier: github:tauri-apps/tauri-plugin-store#v1
version: github.com/tauri-apps/tauri-plugin-store/1467ba770623ab1d41d825841c3d9435d9eaa0f1
specifier: github:tauri-apps/tauri-plugin-log#v1
version: github.com/tauri-apps/tauri-plugin-log/fbbb126e6d7fba7a7e6772d33f99c0fb689f32b6
devDependencies:
'@tauri-apps/cli':
@@ -878,6 +875,11 @@ packages:
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
/@tauri-apps/api@1.4.0:
resolution: {integrity: sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
/@tauri-apps/cli-darwin-arm64@1.3.1:
resolution: {integrity: sha512-QlepYVPgOgspcwA/u4kGG4ZUijlXfdRtno00zEy+LxinN/IRXtk+6ErVtsmoLi1ZC9WbuMwzAcsRvqsD+RtNAg==}
engines: {node: '>= 10'}
@@ -1465,18 +1467,10 @@ packages:
engines: {node: '>= 6'}
dev: false
github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/21921031d74f871180381317a338559f588ad8e9}
github.com/tauri-apps/tauri-plugin-log/fbbb126e6d7fba7a7e6772d33f99c0fb689f32b6:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/fbbb126e6d7fba7a7e6772d33f99c0fb689f32b6}
name: tauri-plugin-log-api
version: 0.0.0
dependencies:
'@tauri-apps/api': 1.3.0
dev: false
github.com/tauri-apps/tauri-plugin-store/1467ba770623ab1d41d825841c3d9435d9eaa0f1:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-store/tar.gz/1467ba770623ab1d41d825841c3d9435d9eaa0f1}
name: tauri-plugin-store-api
version: 0.0.0
dependencies:
'@tauri-apps/api': 1.3.0
'@tauri-apps/api': 1.4.0
dev: false

View File

@@ -23,7 +23,6 @@ tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", br
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
env_logger = "0.10"
webkit2gtk = "0.18.2"
regex = "1"
url = "2.3"
@@ -32,6 +31,10 @@ veil = "0.1.6"
whoami = "1.4.1"
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
openssl = "0.10"
keyring = "2"
aes-gcm = { version = "0.10", features = ["std"] }
hex = "0.4"
anyhow = "1.0"
[features]
# by default Tauri runs in production mode

View File

@@ -133,7 +133,7 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
.visible(false)
.title("GlobalProtect Login")
.inner_size(400.0, 647.0)
.inner_size(600.0, 500.0)
.min_inner_size(370.0, 600.0)
.user_agent(ua)
.always_on_top(true)

View File

@@ -1,9 +1,11 @@
use crate::{
auth::{self, AuthData, AuthRequest, SamlBinding, SamlLoginParams},
storage::{AppStorage, KeyHint},
utils::get_openssl_conf,
utils::get_openssl_conf_path,
};
use gpcommon::{Client, ServerApiError, VpnStatus};
use serde_json::Value;
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::fs;
@@ -24,9 +26,10 @@ pub(crate) async fn vpn_status<'a>(
pub(crate) async fn vpn_connect<'a>(
server: String,
cookie: String,
user_agent: String,
client: State<'a, Arc<Client>>,
) -> Result<(), ServerApiError> {
client.connect(server, cookie).await
client.connect(server, cookie, user_agent).await
}
#[tauri::command]
@@ -40,10 +43,10 @@ pub(crate) async fn vpn_disconnect<'a>(
pub(crate) async fn saml_login(
binding: SamlBinding,
request: String,
user_agent: 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,
@@ -71,3 +74,29 @@ pub(crate) async fn update_openssl_config(app_handle: AppHandle) -> tauri::Resul
fs::write(openssl_conf_path, openssl_conf).await?;
Ok(())
}
#[tauri::command]
pub(crate) async fn store_get<'a>(
hint: KeyHint<'_>,
app_storage: State<'_, AppStorage<'_>>,
) -> Result<Option<Value>, ()> {
Ok(app_storage.get(hint))
}
#[tauri::command]
pub(crate) fn store_set(
hint: KeyHint,
value: Value,
app_storage: State<'_, AppStorage>,
) -> Result<(), tauri_plugin_store::Error> {
app_storage.set(hint, &value)?;
Ok(())
}
#[tauri::command]
pub(crate) fn store_save(
app_storage: State<'_, AppStorage>,
) -> Result<(), tauri_plugin_store::Error> {
app_storage.save()?;
Ok(())
}

View File

@@ -0,0 +1,46 @@
use aes_gcm::{
aead::{consts::U12, Aead, OsRng},
AeadCore, Aes256Gcm, Key, KeyInit, Nonce,
};
use keyring::Entry;
const SERVICE_NAME: &str = "GlobalProtect-openconnect";
const ENTRY_KEY: &str = "master-key";
fn get_master_key() -> Result<Key<Aes256Gcm>, anyhow::Error> {
let key_entry = Entry::new(SERVICE_NAME, ENTRY_KEY)?;
if let Ok(key) = key_entry.get_password() {
let key = hex::decode(key)?;
return Ok(Key::<Aes256Gcm>::clone_from_slice(&key));
}
let key = Aes256Gcm::generate_key(OsRng);
let encoded_key = hex::encode(key);
key_entry.set_password(&encoded_key)?;
Ok(key)
}
pub(crate) fn encrypt(data: &str) -> Result<String, anyhow::Error> {
let master_key = get_master_key()?;
let cipher = Aes256Gcm::new(&master_key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let cipher_text = cipher.encrypt(&nonce, data.as_bytes())?;
let mut encrypted = nonce.to_vec();
encrypted.extend_from_slice(&cipher_text);
Ok(hex::encode(encrypted))
}
pub(crate) fn decrypt(encrypted: &str) -> Result<String, anyhow::Error> {
let master_key = get_master_key()?;
let encrypted = hex::decode(encrypted)?;
let nonce = Nonce::<U12>::from_slice(&encrypted[..12]);
let cipher_text = &encrypted[12..];
let cipher = Aes256Gcm::new(&master_key);
let plain_text = cipher.decrypt(nonce, cipher_text)?;
String::from_utf8(plain_text).map_err(|err| err.into())
}

View File

@@ -2,93 +2,26 @@
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use crate::utils::get_openssl_conf_path;
use env_logger::Env;
use gpcommon::{Client, ClientStatus, VpnStatus};
use log::{info, warn};
use serde::Serialize;
use std::{path::PathBuf, sync::Arc};
use tauri::{Manager, Wry};
use tauri_plugin_log::LogTarget;
use tauri_plugin_store::{with_store, StoreCollection};
mod auth;
mod commands;
mod crypto;
mod settings;
mod setup;
mod storage;
mod utils;
#[derive(Debug, Clone, Serialize)]
struct VpnStatusPayload {
status: VpnStatus,
}
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let client = Arc::new(Client::default());
let client_clone = client.clone();
let app_handle = app.handle();
let stores = app.state::<StoreCollection<Wry>>();
let path = PathBuf::from(".settings.dat");
let _ = with_store(app_handle.clone(), stores, path, |store| {
let settings_data = store.get("SETTINGS_DATA");
let custom_openssl = settings_data.map_or(false, |data| {
data["customOpenSSL"].as_bool().unwrap_or(false)
});
if custom_openssl {
info!("Using custom OpenSSL config");
let openssl_conf = get_openssl_conf_path(&app_handle).into_os_string();
std::env::set_var("OPENSSL_CONF", openssl_conf);
}
Ok(())
});
tauri::async_runtime::spawn(async move {
client_clone.subscribe_status(move |client_status| match client_status {
ClientStatus::Vpn(vpn_status) => {
let payload = VpnStatusPayload { status: vpn_status };
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);
}
}
});
let _ = client_clone.run().await;
});
app.manage(client);
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
if desktop == "KDE" {
if let Some(main_window) = app.get_window("main") {
let _ = main_window.set_decorations(false);
}
}
}
Ok(())
}
fn main() {
// env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::default()
.targets([
LogTarget::LogDir,
LogTarget::Stdout, /*LogTarget::Webview*/
])
.targets([LogTarget::LogDir, LogTarget::Stdout])
.level(log::LevelFilter::Info)
.with_colors(Default::default())
.build(),
)
.plugin(tauri_plugin_store::Builder::default().build())
.setup(setup)
.setup(setup::setup)
.invoke_handler(tauri::generate_handler![
commands::service_online,
commands::vpn_status,
@@ -98,6 +31,9 @@ fn main() {
commands::os_version,
commands::openssl_config,
commands::update_openssl_config,
commands::store_get,
commands::store_set,
commands::store_save,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,19 @@
use crate::storage::{AppStorage, KeyHint};
use serde::Deserialize;
use tauri::{AppHandle, Manager};
const STORAGE_KEY: &str = "SETTINGS_DATA";
#[derive(Debug, Deserialize)]
struct Settings {
#[serde(rename = "customOpenSSL")]
custom_openssl: bool,
}
pub(crate) fn is_custom_openssl_enabled(app_handle: &AppHandle) -> bool {
let app_storage = app_handle.state::<AppStorage>();
let hint = KeyHint::new(STORAGE_KEY, false);
let settings = app_storage.get::<Settings>(hint);
settings.map_or(false, |settings| settings.custom_openssl)
}

View File

@@ -0,0 +1,81 @@
use crate::{settings, storage::AppStorage, utils::get_openssl_conf_path};
use gpcommon::{Client, ClientStatus, VpnStatus};
use log::{info, warn};
use serde::Serialize;
use std::sync::Arc;
use tauri::Manager;
#[derive(Debug, Clone, Serialize)]
struct VpnStatusPayload {
status: VpnStatus,
}
fn setup_window(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let desktop = std::env::var("XDG_CURRENT_DESKTOP")?;
if desktop == "KDE" {
if let Some(main_window) = app.get_window("main") {
let _ = main_window.set_decorations(false);
}
}
Ok(())
}
fn setup_app_storage(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let app_handle = app.app_handle();
let app_storage = AppStorage::new(app_handle);
app.manage(app_storage);
Ok(())
}
fn setup_app_env(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let app_handle = app.app_handle();
let use_custom_openssl = settings::is_custom_openssl_enabled(&app_handle);
if use_custom_openssl {
info!("Using custom OpenSSL config");
let openssl_conf = get_openssl_conf_path(&app_handle).into_os_string();
std::env::set_var("OPENSSL_CONF", openssl_conf);
}
Ok(())
}
fn setup_vpn_client(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let app_handle = app.handle();
let client = Arc::new(Client::default());
let client_clone = client.clone();
app.manage(client_clone);
tauri::async_runtime::spawn(async move {
client.subscribe_status(move |client_status| match client_status {
ClientStatus::Vpn(vpn_status) => {
let payload = VpnStatusPayload { status: vpn_status };
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);
}
}
});
let _ = client.run().await;
});
Ok(())
}
pub(crate) fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_window(app)?;
setup_app_storage(app)?;
setup_app_env(app)?;
setup_vpn_client(app)?;
Ok(())
}

View File

@@ -0,0 +1,87 @@
use crate::crypto::{decrypt, encrypt};
use log::warn;
use serde::{de::DeserializeOwned, Deserialize};
use std::fmt::Debug;
use tauri::{AppHandle, Manager, Wry};
use tauri_plugin_store::{with_store, Error, StoreCollection};
const STORE_PATH: &str = ".data.json";
#[derive(Debug, Deserialize)]
pub(crate) struct KeyHint<'a> {
key: &'a str,
encrypted: bool,
}
impl<'a> KeyHint<'a> {
pub(crate) fn new(key: &'a str, encrypted: bool) -> Self {
Self { key, encrypted }
}
}
pub(crate) struct AppStorage<'a> {
path: &'a str,
app_handle: AppHandle<Wry>,
}
impl AppStorage<'_> {
pub(crate) fn new(app_handle: AppHandle<Wry>) -> Self {
Self {
path: STORE_PATH,
app_handle,
}
}
pub fn get<T: DeserializeOwned + Debug>(&self, hint: KeyHint) -> Option<T> {
let stores = self.app_handle.state::<StoreCollection<Wry>>();
with_store(self.app_handle.clone(), stores, self.path, |store| {
store
.get(hint.key)
.ok_or_else(|| Error::Deserialize("Value not found".into()))
.and_then(|value| {
if !hint.encrypted {
return Ok(serde_json::from_value::<T>(value.clone())?);
}
let value = value
.as_str()
.ok_or_else(|| Error::Deserialize("Value is not a string".into()))?;
let value = decrypt(value).map_err(|err| {
Error::Deserialize(format!("Failed to decrypt value: {}", err).into())
})?;
Ok(serde_json::from_str::<T>(&value)?)
})
})
.map_err(|err| warn!("Error getting value: {:?}", err))
.ok()
}
pub fn set<T: serde::Serialize>(&self, hint: KeyHint, value: &T) -> Result<(), Error> {
let stores = self.app_handle.state::<StoreCollection<Wry>>();
with_store(self.app_handle.clone(), stores, self.path, |store| {
let value = if hint.encrypted {
let json_str = serde_json::to_string(value)?;
let encrypted = encrypt(&json_str).map_err(|err| {
Error::Serialize(format!("Failed to encrypt value: {}", err).into())
})?;
serde_json::to_value(encrypted)?
} else {
serde_json::to_value(value)?
};
store.insert(hint.key.to_string(), value)?;
Ok(())
})
}
pub fn save(&self) -> Result<(), Error> {
let stores = self.app_handle.state::<StoreCollection<Wry>>();
with_store(self.app_handle.clone(), stores, self.path, |store| {
store.save()?;
Ok(())
})
}
}

View File

@@ -14,7 +14,11 @@ export const portalGatewaysAtom = atom<GatewayData[]>((get) => {
});
export const selectedGatewayAtom = atom(
(get) => get(currentPortalDataAtom).selectedGateway,
(get) => {
const { selectedGateway } = get(currentPortalDataAtom);
const gateways = get(portalGatewaysAtom);
return gateways.find(({ name }) => name === selectedGateway);
},
async (get, set, update: string) => {
const portalData = get(currentPortalDataAtom);
await set(updatePortalDataAtom, { ...portalData, selectedGateway: update });

View File

@@ -62,7 +62,7 @@ export const loginPortalAtom = atom(
}
// Here, we have got the portal config successfully, refresh the cached portal data
const previousSelectedGateway = get(selectedGatewayAtom);
const previousSelectedGateway = get(selectedGatewayAtom)?.name;
const selectedGateway = gateways.find(
({ name }) => name === previousSelectedGateway
);

View File

@@ -1,7 +1,7 @@
import { atom } from "jotai";
import { atomWithDefault } from "jotai/utils";
import { PortalCredential } from "../services/portalService";
import { atomWithTauriStorage } from "../services/storeService";
import { atomWithTauriStorage } from "../services/storageService";
import { unwrap } from "./unwrap";
export type GatewayData = {
@@ -31,7 +31,11 @@ const DEFAULT_APP_DATA: AppData = {
clearCookies: true,
};
export const appDataAtom = atomWithTauriStorage("APP_DATA", DEFAULT_APP_DATA);
const keyHint = {
key: "APP_DATA",
encrypted: true,
};
export const appDataAtom = atomWithTauriStorage(keyHint, DEFAULT_APP_DATA);
const unwrappedAppDataAtom = atom(
(get) => get(unwrap(appDataAtom)) || DEFAULT_APP_DATA
);
@@ -51,6 +55,11 @@ export const currentPortalDataAtom = atom<PortalData>((get) => {
return portalData || { address: portalAddress, gateways: [] };
});
export const allPortalsAtom = atom((get) => {
const { portals } = get(unwrappedAppDataAtom);
return portals.map(({ address }) => address);
});
export const updatePortalDataAtom = atom(
null,
async (get, set, update: PortalData) => {

View File

@@ -5,7 +5,7 @@ import settingsService, {
DEFAULT_SETTINGS_DATA,
SETTINGS_DATA,
} from "../services/settingsService";
import { atomWithTauriStorage } from "../services/storeService";
import { atomWithTauriStorage } from "../services/storageService";
import { unwrap } from "./unwrap";
const settingsDataAtom = atomWithTauriStorage(

View File

@@ -4,6 +4,7 @@ import vpnService from "../services/vpnService";
import { selectedGatewayAtom, switchGatewayAtom } from "./gateway";
import { notifyErrorAtom, notifySuccessAtom } from "./notification";
import { unwrap } from "./unwrap";
import { portalAddressAtom } from "./portal";
export type Status =
| "disconnected"
@@ -75,14 +76,16 @@ export const statusTextAtom = atom<string>((get) => {
if (status === "connected") {
const selectedGateway = get(selectedGatewayAtom);
return selectedGateway
? `Gateway: ${selectedGateway}`
: statusTextMap[status];
const portalAddress = get(portalAddressAtom);
return selectedGateway?.address === portalAddress
? statusTextMap[status]
: selectedGateway?.address!;
}
if (switchingGateway) {
const selectedGateway = get(selectedGatewayAtom);
return `Switching to ${selectedGateway}`;
return `Switching to ${selectedGateway?.name}`;
}
return statusTextMap[status];

View File

@@ -1,13 +1,8 @@
import {
Box,
CssBaseline,
ThemeProvider,
createTheme,
useMediaQuery,
} from "@mui/material";
import React, { Suspense, useMemo } from "react";
import { Box, CssBaseline, ThemeProvider } from "@mui/material";
import React, { Suspense } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import useGlobalTheme from "./useGlobalTheme";
function Loading() {
console.warn("Loading rendered");
@@ -27,16 +22,7 @@ function Loading() {
}
function AppShell({ children }: { children: React.ReactNode }) {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() =>
createTheme({
palette: {
mode: prefersDarkMode ? "dark" : "light",
},
}),
[prefersDarkMode]
);
const theme = useGlobalTheme();
return (
<React.StrictMode>

View File

@@ -0,0 +1,31 @@
import { createTheme, useMediaQuery } from "@mui/material";
import { useMemo } from "react";
export default function useGlobalTheme() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
return useMemo(
() =>
createTheme({
palette: {
mode: prefersDarkMode ? "dark" : "light",
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: "none",
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: "none",
},
},
},
},
}),
[prefersDarkMode]
);
}

View File

@@ -57,7 +57,7 @@ export default function PasswordAuth() {
<Button
variant="outlined"
onClick={cancelPasswordAuth}
sx={{ flex: 1, textTransform: "none" }}
sx={{ flex: 1 }}
>
Cancel
</Button>
@@ -66,7 +66,6 @@ export default function PasswordAuth() {
type="submit"
loading={loading}
disabled={loading}
sx={{ flex: 1, textTransform: "none" }}
>
Login
</LoadingButton>

View File

@@ -1,12 +1,12 @@
import { Button, TextField } from "@mui/material";
import { Autocomplete, Button, TextField } from "@mui/material";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { ChangeEvent } from "react";
import { ChangeEvent, useState } from "react";
import {
cancelConnectPortalAtom,
connectPortalAtom,
} from "../../atoms/connectPortal";
import { switchGatewayAtom } from "../../atoms/gateway";
import { portalAddressAtom } from "../../atoms/portal";
import { allPortalsAtom, portalAddressAtom } from "../../atoms/portal";
import {
backgroundServiceStartedAtom,
isProcessingAtom,
@@ -26,6 +26,7 @@ function normalizePortalAddress(input: string) {
export default function PortalForm() {
const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom);
const allPortals = useAtomValue(allPortalsAtom);
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
// Use useAtom instead of useSetAtom, otherwise the onMount of the atom is not triggered
const [, connectPortal] = useAtom(connectPortalAtom);
@@ -35,8 +36,10 @@ export default function PortalForm() {
const disconnectVpn = useSetAtom(disconnectVpnAtom);
const switchingGateway = useAtomValue(switchGatewayAtom);
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
setPortalAddress(normalizePortalAddress(e.target.value));
const readOnly = status !== "disconnected" || switchingGateway;
function handlePortalAddressChange(e: unknown, value: string) {
setPortalAddress(normalizePortalAddress(value));
}
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
@@ -46,16 +49,35 @@ export default function PortalForm() {
return (
<form onSubmit={handleSubmit} data-tauri-drag-region>
<TextField
autoFocus
label="Portal address"
placeholder="Hostname or IP address"
fullWidth
<Autocomplete
freeSolo
options={allPortals}
inputValue={portalAddress}
onInputChange={handlePortalAddressChange}
readOnly={readOnly}
forcePopupIcon={!readOnly}
disableClearable
size="small"
value={portalAddress}
onChange={handlePortalAddressChange}
InputProps={{ readOnly: status !== "disconnected" || switchingGateway }}
sx={{ mb: 1 }}
sx={{
mb: 1,
}}
componentsProps={{
paper: {
sx: {
"& .MuiAutocomplete-listbox .MuiAutocomplete-option": {
minHeight: "auto",
},
},
},
}}
renderInput={(params) => (
<TextField
{...params}
autoFocus
label="Portal address"
placeholder="Hostname or IP address"
/>
)}
/>
{status === "disconnected" && !switchingGateway && (
@@ -64,7 +86,6 @@ export default function PortalForm() {
type="submit"
variant="contained"
disabled={!backgroundServiceStarted}
sx={{ textTransform: "none" }}
>
Connect
</Button>
@@ -81,19 +102,13 @@ export default function PortalForm() {
switchingGateway
}
onClick={cancelConnectPortal}
sx={{ textTransform: "none" }}
>
Cancel
</Button>
)}
{status === "connected" && (
<Button
fullWidth
variant="contained"
onClick={disconnectVpn}
sx={{ textTransform: "none" }}
>
<Button fullWidth variant="contained" onClick={disconnectVpn}>
Disconnect
</Button>
)}

View File

@@ -1,7 +1,18 @@
import { GppBad, VerifiedUser as VerifiedIcon } from "@mui/icons-material";
import { Box, CircularProgress, styled, useTheme } from "@mui/material";
import { useAtomValue } from "jotai";
import {
Box,
Button,
CircularProgress,
Tooltip,
styled,
useTheme,
} from "@mui/material";
import { useAtomValue, useSetAtom } from "jotai";
import { BeatLoader } from "react-spinners";
import {
openGatewaySwitcherAtom,
selectedGatewayAtom,
} from "../../atoms/gateway";
import { isProcessingAtom, statusAtom } from "../../atoms/status";
function useStatusColor() {
@@ -59,11 +70,48 @@ function ProcessingIcon() {
return <BeatLoader color={theme.palette.info.main} />;
}
const ConnectedIcon = styled(VerifiedIcon)(({ theme }) => ({
position: "relative",
fontSize: 80,
color: theme.palette.success.main,
}));
const ConnectedIcon = () => {
const selectedGateway = useAtomValue(selectedGatewayAtom);
const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom);
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<VerifiedIcon
sx={{
fontSize: 70,
color: (theme) => theme.palette.success.main,
}}
/>
<Tooltip title={`Connected to ${selectedGateway?.name}`}>
<Button
sx={{
position: "relative",
zIndex: 1,
fontSize: "0.75rem",
fontWeight: "bold",
display: "block",
width: 100,
mt: 0.2,
padding: 0.2,
overflow: "hidden",
textOverflow: "ellipsis",
}}
size="small"
color="success"
onClick={openGatewaySwitcher}
>
{selectedGateway?.name}
</Button>
</Tooltip>
</Box>
);
};
const IconContainer = styled(Box)(({ theme }) =>
theme.unstable_sx({

View File

@@ -20,7 +20,7 @@ export default function GatewaySwitcher() {
gatewaySwitcherVisibleAtom
);
const gateways = useAtomValue(portalGatewaysAtom);
const selectedGateway = useAtomValue(selectedGatewayAtom);
const selectedGateway = useAtomValue(selectedGatewayAtom)?.name;
const switchGateway = useSetAtom(switchGatewayAtom);
const handleClose = () => {

View File

@@ -43,14 +43,12 @@ export default function SettingsPanel() {
value="simulation"
icon={<Devices />}
iconPosition="start"
sx={{ textTransform: "none" }}
/>
<Tab
label="OpenSSL"
value="openssl"
icon={<Https />}
iconPosition="start"
sx={{ textTransform: "none" }}
/>
</TabList>
<Box sx={{ flex: 1 }}>
@@ -61,12 +59,8 @@ export default function SettingsPanel() {
</Box>
<Box sx={{ flexShrink: 0, borderTop: 1, borderColor: "divider" }}>
<DialogActions>
<Button sx={{ textTransform: "none" }} onClick={closeWindow}>
Cancel
</Button>
<Button sx={{ textTransform: "none" }} onClick={save}>
Save
</Button>
<Button onClick={closeWindow}>Cancel</Button>
<Button onClick={save}>Save</Button>
</DialogActions>
</Box>
</Box>

View File

@@ -1,4 +1,6 @@
import { Box } from "@mui/material";
import { useAtomValue } from "jotai";
import { appDataAtom } from "../atoms/portal";
import { renderToRoot } from "../components/AppShell";
import ConnectForm from "../components/ConnectForm";
import ConnectionStatus from "../components/ConnectionStatus";
@@ -8,6 +10,10 @@ import MainMenu from "../components/MainMenu";
import Notification from "../components/Notification";
export default function App() {
// Use the this atom to trigger loading data from the storage
// And render the loading indicator
useAtomValue(appDataAtom);
return (
<Box data-tauri-drag-region padding={2} paddingBottom={0}>
<MainMenu />

View File

@@ -1,5 +1,6 @@
import { emit, listen } from "@tauri-apps/api/event";
import invokeCommand from "../utils/invokeCommand";
import settingsService from "./settingsService";
export type AuthData = {
username: string;
@@ -30,9 +31,12 @@ class AuthService {
// binding: "POST" | "REDIRECT"
async samlLogin(binding: string, request: string, clearCookies: boolean) {
const { userAgent } = await settingsService.getSimulation();
return invokeCommand<AuthData>("saml_login", {
binding,
request,
userAgent: `${userAgent} ${navigator.userAgent}`,
clearCookies,
});
}

View File

@@ -1,5 +1,6 @@
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import { parseXml } from "../utils/parseXml";
import settingsService from "./settingsService";
type LoginParams = {
user: string;
@@ -15,6 +16,9 @@ class GatewayService {
throw new Error("Gateway address is required");
}
const { userAgent, clientOS, osVersion } =
await settingsService.getSimulation();
const loginUrl = `https://${gateway}/ssl-vpn/login.esp`;
const body = Body.form({
prot: "https:",
@@ -25,8 +29,8 @@ class GatewayService {
direct: "yes",
"ipv6-support": "yes",
clientVer: "4100",
clientos: "Linux",
"os-version": "Linux",
clientos: clientOS,
"os-version": osVersion,
server: gateway,
user,
passwd: passwd || "",
@@ -38,7 +42,7 @@ class GatewayService {
const response = await fetch<string>(loginUrl, {
method: "POST",
headers: {
"User-Agent": "PAN GlobalProtect",
"User-Agent": userAgent,
},
responseType: ResponseType.Text,
body,

View File

@@ -2,6 +2,7 @@ import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import ErrorWithTitle from "../utils/ErrorWithTitle";
import { parseXml } from "../utils/parseXml";
import { Gateway } from "./types";
import settingsService from "./settingsService";
export type SamlPrelogin = {
isSamlAuth: true;
@@ -37,12 +38,15 @@ export type PortalCredential = {
class PortalService {
async prelogin(portal: string): Promise<Prelogin> {
const preloginUrl = `https://${portal}/global-protect/prelogin.esp`;
const { userAgent, clientOS, osVersion } =
await settingsService.getSimulation();
let response;
try {
response = await fetch<string>(preloginUrl, {
method: "POST",
headers: {
"User-Agent": "PAN GlobalProtect",
"User-Agent": userAgent,
},
responseType: ResponseType.Text,
query: {
@@ -51,8 +55,8 @@ class PortalService {
body: Body.form({
tmp: "tmp",
clientVer: "4100",
clientos: "Linux",
"os-version": "Linux",
clientos: clientOS,
"os-version": osVersion,
"ipv6-support": "yes",
"default-browser": "0",
"cas-support": "yes",
@@ -118,6 +122,9 @@ class PortalService {
}
async fetchConfig(portal: string, params: PortalCredential) {
const { userAgent, clientOS, osVersion, clientVersion } =
await settingsService.getSimulation();
const {
user,
passwd,
@@ -132,12 +139,12 @@ class PortalService {
inputStr: "",
jnlpReady: "jnlpReady",
computer: "Linux", // TODO
clientos: "Linux",
clientos: clientOS,
ok: "Login",
direct: "yes",
clientVer: "4100",
"os-version": "Linux",
clientgpversion: "6.0.1-19",
"os-version": osVersion,
clientgpversion: clientVersion,
"ipv6-support": "yes",
server: portal,
host: portal,
@@ -151,7 +158,7 @@ class PortalService {
const response = await fetch<string>(configUrl, {
method: "POST",
headers: {
"User-Agent": "PAN GlobalProtect",
"User-Agent": userAgent,
},
responseType: ResponseType.Text,
body,
@@ -166,8 +173,6 @@ class PortalService {
}
private parsePortalConfigResponse(response: string): PortalConfig {
// console.log(response);
const result = parseXml(response);
const gateways = result.all("gateways list > entry").map((entry) => {
const address = entry.attr("name");

View File

@@ -1,6 +1,6 @@
import { UserAttentionType, WebviewWindow } from "@tauri-apps/api/window";
import invokeCommand from "../utils/invokeCommand";
import { appStore } from "./storeService";
import { appStorage } from "./storageService";
export type TabValue = "simulation" | "openssl";
const SETTINGS_WINDOW_LABEL = "settings";
@@ -68,9 +68,10 @@ export const DEFAULT_SETTINGS_DATA: SettingsData = {
customOpenSSL: false,
};
async function getSimulationSettings(): Promise<SimulationSettings> {
async function getSimulation(): Promise<SimulationSettings> {
const { clientOS, osVersion, clientVersion } =
(await appStore.get<SettingsData>(SETTINGS_DATA)) || DEFAULT_SETTINGS_DATA;
(await appStorage.get<SettingsData>(SETTINGS_DATA)) ||
DEFAULT_SETTINGS_DATA;
const currentOsVersion = await getCurrentOsVersion();
return {
@@ -81,8 +82,8 @@ async function getSimulationSettings(): Promise<SimulationSettings> {
clientVersion
),
clientOS,
osVersion,
clientVersion,
osVersion: determineOsVersion(clientOS, osVersion, currentOsVersion),
clientVersion: clientVersion || DEFAULT_CLIENT_VERSION,
};
}
@@ -131,7 +132,7 @@ export default {
openSettings,
closeSettings,
getCurrentOsVersion,
getSimulationSettings,
getSimulation,
buildUserAgent,
determineOsVersion,
getOpenSSLConfig,

View File

@@ -0,0 +1,70 @@
import { atom } from "jotai";
import { RESET, atomWithDefault } from "jotai/utils";
import invokeCommand from "../utils/invokeCommand";
type SetStateActionWithReset<T> =
| T
| typeof RESET
| ((prev: T) => T | typeof RESET);
type KeyHint =
| {
key: string;
encrypted: boolean;
}
| string;
type AppStorage = {
get: <T>(key: KeyHint) => Promise<T | undefined>;
set: <T>(key: KeyHint, value: T) => Promise<void>;
save: () => Promise<void>;
};
export const appStorage: AppStorage = {
get: async (key) => {
const hint = typeof key === "string" ? { key, encrypted: false } : key;
return invokeCommand("store_get", { hint });
},
set: async (key, value) => {
const hint = typeof key === "string" ? { key, encrypted: false } : key;
return invokeCommand("store_set", { hint, value });
},
save: async () => {
return invokeCommand("store_save");
},
};
export function atomWithTauriStorage<T>(key: KeyHint, initialValue: T) {
const baseAtom = atomWithDefault<T | Promise<T>>(async () => {
const storedValue = await appStorage.get<T>(key);
if (!storedValue) {
return initialValue;
}
return storedValue;
});
const anAtom = atom(
(get) => get(baseAtom),
async (get, set, update: SetStateActionWithReset<T>) => {
const value = await get(baseAtom);
let newValue: T | typeof RESET;
if (typeof update === "function") {
newValue = (update as (prev: T) => T | typeof RESET)(value);
} else {
newValue = update as T | typeof RESET;
}
if (newValue === RESET) {
set(baseAtom, initialValue);
await appStorage.set(key, initialValue);
} else {
set(baseAtom, newValue);
await appStorage.set(key, newValue);
}
await appStorage.save();
}
);
return anAtom;
}

View File

@@ -1,45 +0,0 @@
import { atom } from "jotai";
import { RESET, atomWithDefault } from "jotai/utils";
import { Store } from "tauri-plugin-store-api";
type SetStateActionWithReset<T> =
| T
| typeof RESET
| ((prev: T) => T | typeof RESET);
export const appStore = new Store(".settings.dat");
export function atomWithTauriStorage<T>(key: string, initialValue: T) {
const baseAtom = atomWithDefault<T | Promise<T>>(async () => {
const storedValue = await appStore.get<T>(key);
if (!storedValue) {
return initialValue;
}
return storedValue;
});
const anAtom = atom(
(get) => get(baseAtom),
async (get, set, update: SetStateActionWithReset<T>) => {
const value = await get(baseAtom);
let newValue: T | typeof RESET;
if (typeof update === "function") {
newValue = (update as (prev: T) => T | typeof RESET)(value);
} else {
newValue = update as T | typeof RESET;
}
if (newValue === RESET) {
set(baseAtom, initialValue);
await appStore.set(key, initialValue);
} else {
set(baseAtom, newValue);
await appStore.set(key, newValue);
}
await appStore.save();
}
);
return anAtom;
}

View File

@@ -1,5 +1,6 @@
import { Event, listen } from "@tauri-apps/api/event";
import invokeCommand from "../utils/invokeCommand";
import settingsService from "./settingsService";
type VpnStatus = "disconnected" | "connecting" | "connected" | "disconnecting";
type VpnStatusCallback = (status: VpnStatus) => void;
@@ -64,7 +65,8 @@ class VpnService {
}
async connect(server: string, cookie: string) {
return invokeCommand("vpn_connect", { server, cookie });
const { userAgent } = await settingsService.getSimulation();
return invokeCommand("vpn_connect", { server, cookie, userAgent });
}
async disconnect() {