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