mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-05-20 07:26:58 -04:00
refactor: rewrite
This commit is contained in:
10
gpgui/src/App.css
Normal file
10
gpgui/src/App.css
Normal 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
202
gpgui/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
gpgui/src/assets/react.svg
Normal file
1
gpgui/src/assets/react.svg
Normal 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 |
104
gpgui/src/components/ConnectionStatus.tsx
Normal file
104
gpgui/src/components/ConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
gpgui/src/components/Notification.tsx
Normal file
68
gpgui/src/components/Notification.tsx
Normal 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>
|
||||
);
|
||||
}
|
120
gpgui/src/components/PasswordAuth.tsx
Normal file
120
gpgui/src/components/PasswordAuth.tsx
Normal 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
11
gpgui/src/main.tsx
Normal 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>,
|
||||
)
|
70
gpgui/src/services/gatewayService.ts
Normal file
70
gpgui/src/services/gatewayService.ts
Normal 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();
|
158
gpgui/src/services/portalService.ts
Normal file
158
gpgui/src/services/portalService.ts
Normal 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();
|
13
gpgui/src/services/types.ts
Normal file
13
gpgui/src/services/types.ts
Normal 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>;
|
||||
};
|
72
gpgui/src/services/vpnService.ts
Normal file
72
gpgui/src/services/vpnService.ts
Normal 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
5
gpgui/src/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Maybe<T> = T | null | undefined;
|
||||
|
||||
export type MaybeProperties<T> = {
|
||||
[P in keyof T]?: Maybe<T[P]>;
|
||||
};
|
31
gpgui/src/utils/parseXml.ts
Normal file
31
gpgui/src/utils/parseXml.ts
Normal 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
1
gpgui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
Reference in New Issue
Block a user