refactor: rewrite

This commit is contained in:
Kevin Yue
2023-02-17 01:21:36 -05:00
parent 7bef2ccc68
commit 19b9b757f4
194 changed files with 7885 additions and 8034 deletions

24
gpgui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<jnlp>
<application-desc>
<argument>(null)</argument>
<argument>44d9988f3a3b5a247d359b1d39229add</argument>
<argument>1cb389d44ec35e98665211761b65a049ef7ba77e</argument>
<argument>GP-Gateway-N</argument>
<argument>user</argument>
<argument>AD_Authentication</argument>
<argument>vsys1</argument>
<argument>vpn.example.com</argument>
<argument>(null)</argument>
<argument></argument>
<argument></argument>
<argument></argument>
<argument>tunnel</argument>
<argument>-1</argument>
<argument>4100</argument>
<argument></argument>
<argument>xxxxxxxxxxxxxxxx</argument>
<argument>xxxxxxxxxxxxxxxx</argument>
<argument></argument>
<argument>4</argument>
<argument>unknown</argument>
<argument></argument>
</application-desc>
</jnlp>

View File

@@ -0,0 +1,212 @@
<?xml version="1.0" encoding="UTF-8"?>
<policy>
<portal-name>vpn.example.com</portal-name>
<portal-config-version>4100</portal-config-version>
<version>6.0.1-19 </version>
<client-role>global-protect-full</client-role>
<agent-user-override-key>****</agent-user-override-key>
<root-ca>
<entry name="DigiCert Global Root CA">
<cert>
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
</cert>
<install-in-cert-store>yes</install-in-cert-store>
</entry>
<entry name="Thawte RSA CA 2018">
<cert>
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
</cert>
<install-in-cert-store>yes</install-in-cert-store>
</entry>
<entry name="Temp_VPN_Root_Certificate">
<cert>
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
</cert>
<install-in-cert-store>no</install-in-cert-store>
</entry>
</root-ca>
<connect-method>on-demand</connect-method>
<pre-logon-then-on-demand>yes</pre-logon-then-on-demand>
<refresh-config>yes</refresh-config>
<refresh-config-interval>24</refresh-config-interval>
<authentication-modifier>
<none />
</authentication-modifier>
<authentication-override>
<accept-cookie>yes</accept-cookie>
<generate-cookie>yes</generate-cookie>
<cookie-lifetime>
<lifetime-in-days>365</lifetime-in-days>
</cookie-lifetime>
<cookie-encrypt-decrypt-cert>vpn.example.com</cookie-encrypt-decrypt-cert>
</authentication-override>
<use-sso>yes</use-sso>
<ip-address></ip-address>
<host></host>
<gateways>
<cutoff-time>5</cutoff-time>
<external>
<list>
<entry name="xxx.xxx.xxx.xxx">
<priority-rule>
<entry name="Any">
<priority>1</priority>
</entry>
</priority-rule>
<priority>1</priority>
<description>vpn_gateway</description>
</entry>
</list>
</external>
</gateways>
<gateways-v6>
<cutoff-time>5</cutoff-time>
<external>
<list>
<entry name="vpn_gateway">
<ipv4>xxx.xxx.xxx.xxx</ipv4>
<priority-rule>
<entry name="Any">
<priority>1</priority>
</entry>
</priority-rule>
<priority>1</priority>
</entry>
</list>
</external>
</gateways-v6>
<agent-ui>
<can-save-password>yes</can-save-password>
<passcode></passcode>
<uninstall-passwd></uninstall-passwd>
<agent-user-override-timeout>0</agent-user-override-timeout>
<max-agent-user-overrides>0</max-agent-user-overrides>
<help-page></help-page>
<help-page-2></help-page-2>
<welcome-page>
<display>no</display>
<page></page>
</welcome-page>
<agent-user-override>allowed</agent-user-override>
<enable-advanced-view>yes</enable-advanced-view>
<enable-do-not-display-this-welcome-page-again>yes</enable-do-not-display-this-welcome-page-again>
<can-change-portal>yes</can-change-portal>
<show-agent-icon>yes</show-agent-icon>
<password-expiry-message></password-expiry-message>
<init-panel>no</init-panel>
<user-input-on-top>no</user-input-on-top>
</agent-ui>
<hip-collection>
<hip-report-interval>3600</hip-report-interval>
<max-wait-time>20</max-wait-time>
<collect-hip-data>yes</collect-hip-data>
<default>
<category>
<member>antivirus</member>
<member>anti-spyware</member>
<member>host-info</member>
<member>data-loss-prevention</member>
<member>patch-management</member>
<member>firewall</member>
<member>anti-malware</member>
<member>disk-backup</member>
<member>disk-encryption</member>
</category>
</default>
</hip-collection>
<agent-config>
<save-user-credentials>1</save-user-credentials>
<portal-2fa>no</portal-2fa>
<internal-gateway-2fa>no</internal-gateway-2fa>
<auto-discovery-external-gateway-2fa>no</auto-discovery-external-gateway-2fa>
<manual-only-gateway-2fa>no</manual-only-gateway-2fa>
<disconnect-reasons></disconnect-reasons>
<uninstall>allowed</uninstall>
<client-upgrade>prompt</client-upgrade>
<enable-signout>yes</enable-signout>
<use-sso-pin>no</use-sso-pin>
<use-sso-macos>no</use-sso-macos>
<logout-remove-sso>yes</logout-remove-sso>
<krb-auth-fail-fallback>yes</krb-auth-fail-fallback>
<default-browser>no</default-browser>
<retry-tunnel>30</retry-tunnel>
<retry-timeout>5</retry-timeout>
<traffic-enforcement>no</traffic-enforcement>
<enforce-globalprotect>no</enforce-globalprotect>
<enforcer-exception-list />
<enforcer-exception-list-domain />
<captive-portal-exception-timeout>0</captive-portal-exception-timeout>
<captive-portal-login-url></captive-portal-login-url>
<traffic-blocking-notification-delay>15</traffic-blocking-notification-delay>
<display-traffic-blocking-notification-msg>yes</display-traffic-blocking-notification-msg>
<traffic-blocking-notification-msg>&lt;div style=&quot;font-family:'Helvetica
Neue';&quot;&gt;&lt;h1 style=&quot;color:red;text-align:center; margin: 0; font-size:
30px;&quot;&gt;Notice&lt;/h1&gt;&lt;p style=&quot;margin: 0;font-size: 15px;
line-height: 1.2em;&quot;&gt;To access the network, you must first connect to
GlobalProtect.&lt;/p&gt;&lt;/div&gt;</traffic-blocking-notification-msg>
<allow-traffic-blocking-notification-dismissal>yes</allow-traffic-blocking-notification-dismissal>
<display-captive-portal-detection-msg>no</display-captive-portal-detection-msg>
<captive-portal-detection-msg>&lt;div style=&quot;font-family:'Helvetica
Neue';&quot;&gt;&lt;h1 style=&quot;color:red;text-align:center; margin: 0; font-size:
30px;&quot;&gt;Captive Portal Detected&lt;/h1&gt;&lt;p style=&quot;margin: 0; font-size:
15px; line-height: 1.2em;&quot;&gt;GlobalProtect has temporarily permitted network
access for you to connect to the Internet. Follow instructions from your internet
provider.&lt;/p&gt;&lt;p style=&quot;margin: 0; font-size: 15px; line-height:
1.2em;&quot;&gt;If you let the connection time out, open GlobalProtect and click Connect
to try again.&lt;/p&gt;&lt;/div&gt;</captive-portal-detection-msg>
<captive-portal-notification-delay>5</captive-portal-notification-delay>
<certificate-store-lookup>user-and-machine</certificate-store-lookup>
<scep-certificate-renewal-period>7</scep-certificate-renewal-period>
<ext-key-usage-oid-for-client-cert></ext-key-usage-oid-for-client-cert>
<retain-connection-smartcard-removal>yes</retain-connection-smartcard-removal>
<user-accept-terms-before-creating-tunnel>no</user-accept-terms-before-creating-tunnel>
<rediscover-network>yes</rediscover-network>
<resubmit-host-info>yes</resubmit-host-info>
<can-continue-if-portal-cert-invalid>yes</can-continue-if-portal-cert-invalid>
<user-switch-tunnel-rename-timeout>0</user-switch-tunnel-rename-timeout>
<pre-logon-tunnel-rename-timeout>0</pre-logon-tunnel-rename-timeout>
<preserve-tunnel-upon-user-logoff-timeout>0</preserve-tunnel-upon-user-logoff-timeout>
<ipsec-failover-ssl>0</ipsec-failover-ssl>
<display-tunnel-fallback-notification>yes</display-tunnel-fallback-notification>
<ssl-only-selection>0</ssl-only-selection>
<tunnel-mtu>1400</tunnel-mtu>
<max-internal-gateway-connection-attempts>0</max-internal-gateway-connection-attempts>
<adv-internal-host-detection>no</adv-internal-host-detection>
<portal-timeout>30</portal-timeout>
<connect-timeout>60</connect-timeout>
<receive-timeout>30</receive-timeout>
<split-tunnel-option>network-traffic</split-tunnel-option>
<enforce-dns>yes</enforce-dns>
<append-local-search-domain>no</append-local-search-domain>
<flush-dns>no</flush-dns>
<auto-proxy-pac></auto-proxy-pac>
<proxy-multiple-autodetect>no</proxy-multiple-autodetect>
<use-proxy>yes</use-proxy>
<wsc-autodetect>yes</wsc-autodetect>
<mfa-enabled>no</mfa-enabled>
<mfa-listening-port>4501</mfa-listening-port>
<mfa-trusted-host-list />
<mfa-notification-msg>You have attempted to access a protected resource that requires
additional authentication. Proceed to authenticate at</mfa-notification-msg>
<mfa-prompt-suppress-time>0</mfa-prompt-suppress-time>
<ipv6-preferred>yes</ipv6-preferred>
<change-password-message></change-password-message>
<log-gateway>no</log-gateway>
<cdl-log>no</cdl-log>
<dem-notification>yes</dem-notification>
<diagnostic-servers />
<dem-agent>not-install</dem-agent>
<quarantine-add-message>Access to the network from this device has been restricted as per
your organization's security policy. Please contact your IT Administrator.</quarantine-add-message>
<quarantine-remove-message>Access to the network from this device has been restored as per
your organization's security policy.</quarantine-remove-message>
</agent-config>
<user-email>user@example.com</user-email>
<portal-userauthcookie>xxxxxx</portal-userauthcookie>
<portal-prelogonuserauthcookie>xxxxxx</portal-prelogonuserauthcookie>
<config-digest>2d8e997765a2f59cbf80284b2f2fbd38</config-digest>
</policy>

13
gpgui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
gpgui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "gpgui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/lab": "5.0.0-alpha.125",
"@mui/material": "^5.11.11",
"@tauri-apps/api": "^1.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spinners": "^0.13.8"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.3",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react-swc": "^3.0.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
}
}

1391
gpgui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
gpgui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

3
gpgui/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

View File

@@ -0,0 +1,29 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
default-run = "app"
edition = "2021"
rust-version = "1.59"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2.1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2.4", features = ["http-all"] }
common = { path = "../../common" }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ]
# this feature is used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = [ "tauri/custom-protocol" ]

3
gpgui/src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,65 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use common::{Client, ServerApiError, VpnStatus};
use serde::Serialize;
use std::sync::Arc;
use tauri::{Manager, State};
#[tauri::command]
async fn vpn_status<'a>(client: State<'a, Arc<Client>>) -> Result<VpnStatus, ServerApiError> {
client.status().await
}
#[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)]
struct StatusPayload {
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();
tauri::async_runtime::spawn(async move {
let _ = client_clone.subscribe_status(move |status| {
let payload = StatusPayload { status };
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
println!("Error emmiting event: {}", err);
}
});
let _ = client_clone.run().await;
});
app.manage(client);
Ok(())
}
fn main() {
tauri::Builder::default()
.setup(setup)
.invoke_handler(tauri::generate_handler![
vpn_status,
vpn_connect,
vpn_disconnect
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,70 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"devPath": "http://localhost:5173",
"distDir": "../dist"
},
"package": {
"productName": "gpgui",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"http": {
"all": true,
"request": true,
"scope": ["https://**"]
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"height": 360,
"resizable": false,
"title": "GlobalProtect",
"width": 260
}
]
}
}

10
gpgui/src/App.css Normal file
View File

@@ -0,0 +1,10 @@
html {
height: 100%;
-webkit-user-select: none;
user-select: none;
}
body {
height: 100%;
background: #f6f6f6 !important;
}

202
gpgui/src/App.tsx Normal file
View File

@@ -0,0 +1,202 @@
import { Box, TextField } from "@mui/material";
import Button from "@mui/material/Button";
import { ChangeEvent, useEffect, useState } from "react";
import "./App.css";
import ConnectionStatus, { Status } from "./components/ConnectionStatus";
import Notification, { NotificationConfig } from "./components/Notification";
import PasswordAuth, {
Credentials,
PasswordAuthData,
} from "./components/PasswordAuth";
import gatewayService from "./services/gatewayService";
import portalService from "./services/portalService";
import vpnService from "./services/vpnService";
export default function App() {
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() {
setProcessing(true);
// setStatus("connecting");
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 (
<Box padding={2} paddingTop={3}>
<ConnectionStatus sx={{ mb: 2 }} status={processing ? "processing" : status} />
<TextField
autoFocus
label="Portal address"
placeholder="Hostname or IP address"
fullWidth
size="small"
value={portalAddress}
onChange={handlePortalChange}
InputProps={{ readOnly: status !== "disconnected" }}
/>
<Box sx={{ mt: 1.5 }}>
{status === "disconnected" && (
<Button
variant="contained"
fullWidth
onClick={handleConnect}
sx={{ textTransform: "none" }}
>
Connect
</Button>
)}
{status === "connecting" && (
<Button
variant="outlined"
fullWidth
onClick={handleCancel}
sx={{ textTransform: "none" }}
>
Cancel
</Button>
)}
{status === "connected" && (
<Button
variant="contained"
fullWidth
onClick={handleDisconnect}
sx={{ textTransform: "none" }}
>
Disconnect
</Button>
)}
</Box>
<PasswordAuth
open={passwordAuthOpen}
authData={passwordAuth}
authenticating={passwordAuthenticating}
onCancel={cancelPasswordAuth}
onLogin={handlePasswordAuth}
/>
<Notification {...notification} onClose={closeNotification} />
</Box>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,104 @@
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>
);
}

View File

@@ -0,0 +1,68 @@
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>
);
}

View File

@@ -0,0 +1,120 @@
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>
);
}

11
gpgui/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { CssBaseline } from '@mui/material'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<CssBaseline />
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,70 @@
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import { Maybe } from "../types";
import { parseXml } from "../utils/parseXml";
import { Gateway } from "./types";
type LoginParams = {
gateway: Gateway;
username: string;
password: string;
userAuthCookie: Maybe<string>;
};
class GatewayService {
async login(params: LoginParams) {
const { gateway, username, password, userAuthCookie } = params;
if (!gateway.address) {
throw new Error("Gateway address is required");
}
const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`;
const response = await fetch<string>(loginUrl, {
method: "POST",
headers: {
"User-Agent": "PAN GlobalProtect",
},
responseType: ResponseType.Text,
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.address,
user: username,
passwd: password,
"portal-userauthcookie": userAuthCookie ?? "",
"portal-prelogonuserauthcookie": "",
"prelogin-cookie": "",
}),
});
if (!response.ok) {
throw new Error("Login failed");
}
return this.parseLoginResponse(response.data);
}
private parseLoginResponse(response: string) {
const result = parseXml(response);
const query = new URLSearchParams();
query.append("authcookie", result.text("argument:nth-child(2)"));
query.append("portal", result.text("argument:nth-child(4)"));
query.append("user", result.text("argument:nth-child(5)"));
query.append("domain", result.text("argument:nth-child(8)"));
query.append("preferred-ip", result.text("argument:nth-child(16)"));
query.append("computer", "Linux");
return query.toString();
}
}
export default new GatewayService();

View File

@@ -0,0 +1,158 @@
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import { Maybe, MaybeProperties } from "../types";
import { parseXml } from "../utils/parseXml";
import { Gateway } from "./types";
type SamlPreloginResponse = {
samlAuthMethod: string;
samlAuthRequest: string;
};
type PasswordPreloginResponse = {
labelUsername: string;
labelPassword: string;
authMessage: Maybe<string>;
};
type Region = {
region: string;
};
type PreloginResponse = MaybeProperties<
SamlPreloginResponse & PasswordPreloginResponse & Region
>;
type ConfigResponse = {
userAuthCookie: Maybe<string>;
prelogonUserAuthCookie: Maybe<string>;
preferredGateway: Gateway;
gateways: Gateway[];
};
class PortalService {
async prelogin(portal: string) {
const preloginUrl = `https://${portal}/global-protect/prelogin.esp`;
const response = await fetch<string>(preloginUrl, {
method: "GET",
responseType: ResponseType.Text,
query: {
tmp: "tmp",
"kerberos-support": "yes",
"ipv6-support": "yes",
clientVer: "4100",
clientos: "Linux",
},
});
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, {
method: "POST",
headers: {
"User-Agent": "PAN GlobalProtect",
},
responseType: ResponseType.Text,
body: Body.form({
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) {
console.error(response);
throw new Error(`Failed to fetch portal config: ${response.status}`);
}
return this.parsePortalConfigResponse(response.data);
}
private parsePortalConfigResponse(response: string): ConfigResponse {
const result = parseXml(response);
const gateways = result.all("gateways list > entry").map((entry) => {
const address = entry.attr("name");
const name = entry.text("description");
const priority = entry.text(":scope > priority");
return {
name,
address,
priority: priority ? parseInt(priority, 10) : undefined,
priorityRules: entry.all("priority-rule > entry").map((entry) => {
const name = entry.attr("name");
const priority = entry.text("priority");
return {
name,
priority: priority ? parseInt(priority, 10) : undefined,
};
}),
};
});
return {
userAuthCookie: result.text("portal-userauthcookie"),
prelogonUserAuthCookie: result.text("portal-prelogonuserauthcookie"),
preferredGateway: gateways[0],
gateways,
};
}
}
export default new PortalService();

View File

@@ -0,0 +1,13 @@
import { Maybe } from '../types';
type PriorityRule = {
name: Maybe<string>;
priority: Maybe<number>;
};
export type Gateway = {
name: Maybe<string>;
address: Maybe<string>;
priorityRules: PriorityRule[];
priority: Maybe<number>;
};

View File

@@ -0,0 +1,72 @@
import { invoke } from "@tauri-apps/api";
import { listen } from '@tauri-apps/api/event';
type Status = 'disconnected' | 'connecting' | 'connected' | 'disconnecting'
type StatusCallback = (status: Status) => void
type StatusEvent = {
payload: {
status: Status
}
}
class VpnService {
private _status: Status = 'disconnected';
private statusCallbacks: StatusCallback[] = [];
constructor() {
this.init();
}
private async init() {
const unlisten = await listen('vpn-status-received', (event: StatusEvent) => {
console.log('vpn-status-received', event.payload)
this.setStatus(event.payload.status);
})
const status = await this.status();
this.setStatus(status);
}
private setStatus(status: Status) {
if (this._status != status) {
this._status = status;
this.fireStatusCallbacks();
}
}
private async status(): Promise<Status> {
return this.invokeCommand<Status>("vpn_status");
}
async connect(server: string, cookie: string) {
return this.invokeCommand("vpn_connect", { server, cookie });
}
async disconnect() {
return this.invokeCommand("vpn_disconnect");
}
onStatusChanged(callback: StatusCallback) {
this.statusCallbacks.push(callback);
callback(this._status);
return () => this.removeStatusCallback(callback);
}
private fireStatusCallbacks() {
this.statusCallbacks.forEach(cb => cb(this._status));
}
private removeStatusCallback(callback: StatusCallback) {
this.statusCallbacks = this.statusCallbacks.filter(cb => cb !== callback);
}
private async invokeCommand<T>(command: string, args?: any) {
try {
return await invoke<T>(command, args);
} catch (err: any) {
throw new Error(err.message);
}
}
}
export default new VpnService();

5
gpgui/src/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export type Maybe<T> = T | null | undefined;
export type MaybeProperties<T> = {
[P in keyof T]?: Maybe<T[P]>;
};

View File

@@ -0,0 +1,31 @@
type ParseResult = {
text: (selector: string) => string;
all: (selector: string) => ParseResult[];
attr: (selector: string, attr?: string) => string;
};
export function parseXml(xml: string): ParseResult {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
return buildParseResult(doc.documentElement);
}
function buildParseResult(el: Element): ParseResult {
return {
text(selector) {
return el.querySelector(selector)?.textContent ?? '';
},
all(selector) {
return [...el.querySelectorAll(selector)].map((node) => {
return buildParseResult(node);
});
},
attr(attr, selector) {
if (selector) {
return el.querySelector(selector)?.getAttribute(attr) ?? '';
}
return el.getAttribute(attr) ?? '';
},
};
}

1
gpgui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
gpgui/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
gpgui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
gpgui/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})