Compare commits

..

13 Commits

Author SHA1 Message Date
Kevin Yue
aac401e7ee Perform gateway prelogin when failed to login to gateway 2024-01-23 09:26:45 -05:00
Kevin Yue
9655b735a1 Fix ignore TLS errors 2024-01-22 23:20:25 -05:00
Kevin Yue
c3bd7aeb93 Support SSO using default browser 2024-01-22 09:43:44 -05:00
Kevin Yue
0b55a80317 Bump version 2.0.0-beta4 2024-01-21 11:05:15 -05:00
Kevin Yue
c6315bf384 Handle auth window auth fail 2024-01-21 11:04:35 -05:00
Kevin Yue
87b965f80c Add default os-version for CLI 2024-01-21 08:54:08 -05:00
Kevin Yue
b09b21ae0f Bump 2.0.0-beta3 2024-01-21 05:43:49 -05:00
Kevin Yue
7e372cd113 Align with the old behavior of the portal config request (#293) 2024-01-21 18:31:39 +08:00
Kevin Yue
1e211e8912 Update README.md 2024-01-20 22:55:35 -05:00
Kevin Yue
8bc4049a0f Enhancements and Bug Fixes: Align Pre-login Behavior, TLS Error Ignorance, GUI Auto-Launch, and Documentation Improvements (#291) 2024-01-21 10:43:47 +08:00
Kevin Yue
03f8c98cb5 Use uzers crate 2024-01-18 08:54:08 -05:00
Kevin Yue
5c56acc677 Bump version 2.0.0-beta2 2024-01-18 08:51:11 -05:00
Kevin Yue
2d8393dcf7 Update doc (#282) 2024-01-18 20:48:40 +08:00
31 changed files with 735 additions and 477 deletions

View File

@@ -114,137 +114,3 @@ jobs:
name: artifact-${{ matrix.arch }}-tauri
path: |
gpgui/.tmp/artifact
package-rpm:
needs: [setup-matrix, build-tauri]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v4
with:
name: artifact-${{ matrix.arch }}-tauri
path: gpgui/.tmp/artifact
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Create RPM package
run: |
docker run \
--rm \
-v $(pwd):/${{ github.workspace }} \
-w ${{ github.workspace }} \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:rpm-builder \
"./gpgui/scripts/build-rpm.sh"
- name: Upload rpm artifacts
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.arch }}-rpm
path: |
gpgui/.tmp/artifact/*.rpm
package-pkgbuild:
needs: [setup-matrix, build-tauri]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v4
with:
name: artifact-${{ matrix.arch }}-tauri
path: gpgui/.tmp/artifact
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate PKGBUILD
run: |
./gpgui/scripts/generate-pkgbuild.sh
- name: Build PKGBUILD package
run: |
# Generate PKGBUILD to .tmp/pkgbuild
./gpgui/scripts/generate-pkgbuild.sh
# Build package
docker run \
--rm \
-v $(pwd)/gpgui/.tmp/pkgbuild:/pkgbuild \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:pkgbuild
- name: Upload pkgbuild artifacts
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.arch }}-pkgbuild
path: |
gpgui/.tmp/pkgbuild/*.pkg.tar.zst
gh-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs:
- package-rpm
- package-pkgbuild
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
path: artifact
pattern: artifact-*
merge-multiple: true
- name: Generate checksum
uses: jmgilman/actions-generate-checksum@v1
with:
output: checksums.txt
patterns: |
artifact/*
- name: Create GH release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GH_PAT }}
prerelease: contains(github.ref, 'latest')
fail_on_unmatched_files: true
files: |
checksums.txt
artifact/*

10
.vscode/settings.json vendored
View File

@@ -10,8 +10,11 @@
"dotenv",
"dotenvy",
"getconfig",
"globalprotect",
"globalprotectcallback",
"gpapi",
"gpauth",
"gpcallback",
"gpclient",
"gpcommon",
"gpgui",
@@ -42,10 +45,13 @@
"urlencoding",
"userauthcookie",
"utsbuf",
"uzers",
"Vite",
"vpnc",
"vpninfo",
"wmctrl",
"XAUTHORITY"
]
"XAUTHORITY",
"yuezk"
],
"rust-analyzer.cargo.features": "all",
}

84
Cargo.lock generated
View File

@@ -1423,13 +1423,15 @@ dependencies = [
[[package]]
name = "gpapi"
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
dependencies = [
"anyhow",
"base64 0.21.5",
"chacha20poly1305",
"clap",
"dotenvy_macro",
"log",
"open",
"redact-engine",
"regex",
"reqwest",
@@ -1444,13 +1446,13 @@ dependencies = [
"tokio",
"url",
"urlencoding",
"users",
"uzers",
"whoami",
]
[[package]]
name = "gpauth"
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
dependencies = [
"anyhow",
"clap",
@@ -1470,7 +1472,7 @@ dependencies = [
[[package]]
name = "gpclient"
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
dependencies = [
"anyhow",
"clap",
@@ -1491,7 +1493,7 @@ dependencies = [
[[package]]
name = "gpservice"
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
dependencies = [
"anyhow",
"axum",
@@ -1564,9 +1566,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.22"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
dependencies = [
"bytes",
"fnv",
@@ -1583,9 +1585,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.0"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d308f63daf4181410c242d34c11f928dcb3aa105852019e043c9d1f4e4368a"
checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943"
dependencies = [
"bytes",
"fnv",
@@ -1743,7 +1745,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.22",
"h2 0.3.24",
"http 0.2.11",
"http-body 0.4.6",
"httparse",
@@ -1766,7 +1768,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2 0.4.0",
"h2 0.4.2",
"http 1.0.0",
"http-body 1.0.0",
"httparse",
@@ -1962,6 +1964,15 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.10"
@@ -1973,6 +1984,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_executable"
version = "1.0.1"
@@ -2444,9 +2465,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "open"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90878fb664448b54c4e592455ad02831e23a3f7e157374a8b95654731aac7349"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openconnect"
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
dependencies = [
"cc",
"is_executable",
@@ -2573,6 +2605,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pathdiff"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -3070,7 +3108,7 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.3.22",
"h2 0.3.24",
"http 0.2.11",
"http-body 0.4.6",
"hyper 0.14.28",
@@ -4378,16 +4416,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "users"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032"
dependencies = [
"libc",
"log",
]
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -4409,6 +4437,16 @@ dependencies = [
"getrandom 0.2.11",
]
[[package]]
name = "uzers"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76d283dc7e8c901e79e32d077866eaf599156cbf427fffa8289aecc52c5c3f63"
dependencies = [
"libc",
"log",
]
[[package]]
name = "valuable"
version = "0.1.0"

View File

@@ -4,7 +4,7 @@ resolver = "2"
members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"]
[workspace.package]
version = "2.0.0-beta.1"
version = "2.0.0-beta7"
authors = ["Kevin Yue <k3vinyue@gmail.com>"]
homepage = "https://github.com/yuezk/GlobalProtect-openconnect"
edition = "2021"
@@ -36,7 +36,7 @@ futures-util = "0.3"
tokio-tungstenite = "0.20.1"
specta = "=2.0.0-rc.1"
specta-macros = "=2.0.0-rc.1"
users = "0.11"
uzers = "0.11"
whoami = "1"
tauri = { version = "1.5" }
thiserror = "1"

242
README.md
View File

@@ -1,194 +1,128 @@
# GlobalProtect-openconnect
A GlobalProtect VPN client (GUI) for Linux based on Openconnect and built with Qt5, supports SAML auth mode, inspired by [gp-saml-gui](https://github.com/dlenski/gp-saml-gui).
A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authentication method. Inspired by [gp-saml-gui](https://github.com/dlenski/gp-saml-gui).
<p align="center">
<img src="https://user-images.githubusercontent.com/3297602/133869036-5c02b0d9-c2d9-4f87-8c81-e44f68cfd6ac.png">
<img width="300" src="https://github.com/yuezk/GlobalProtect-openconnect/assets/3297602/9242df9c-217d-42ab-8c21-8f9f69cd4eb5">
</p>
<a href="https://paypal.me/zongkun" target="_blank"><img src="https://cdn.jsdelivr.net/gh/everdrone/coolbadge@5ea5937cabca5ecbfc45d6b30592bd81f219bc8d/badges/Paypal/Coffee/Blue/Small.png" alt="Buy me a coffee via Paypal" style="height: 32px; width: 268px;" ></a>
<a href="https://ko-fi.com/M4M75PYKZ" target="_blank"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support me on Ko-fi" style="height: 32px; width: 238px;"></a>
<a href="https://www.buymeacoffee.com/yuezk" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 32px; width: 114px;" ></a>
## Features
- Similar user experience as the official client in macOS.
- Supports both SAML and non-SAML authentication modes.
- Supports automatically selecting the preferred gateway from the multiple gateways.
- Supports switching gateway from the system tray menu manually.
- [x] Better Linux support
- [x] Support both CLI and GUI
- [x] Support both SSO and non-SSO authentication
- [x] Support authentication using default browser
- [x] Support multiple portals
- [x] Support gateway selection
- [x] Support auto-connect on startup
- [x] Support system tray icon
## Usage
## Install
### CLI
|OS|Stable version | Development version|
|---|--------------|--------------------|
|Linux Mint, Ubuntu 18.04 or later|[ppa:yuezk/globalprotect-openconnect](https://launchpad.net/~yuezk/+archive/ubuntu/globalprotect-openconnect)|[ppa:yuezk/globalprotect-openconnect-snapshot](https://launchpad.net/~yuezk/+archive/ubuntu/globalprotect-openconnect-snapshot)|
|Arch, Manjaro|[globalprotect-openconnect](https://archlinux.org/packages/extra/x86_64/globalprotect-openconnect/)|[AUR: globalprotect-openconnect-git](https://aur.archlinux.org/packages/globalprotect-openconnect-git/)|
|Fedora|[copr: yuezk/globalprotect-openconnect](https://copr.fedorainfracloud.org/coprs/yuezk/globalprotect-openconnect/)|[copr: yuezk/globalprotect-openconnect](https://copr.fedorainfracloud.org/coprs/yuezk/globalprotect-openconnect/)|
|openSUSE, CentOS 8|[OBS: globalprotect-openconnect](https://build.opensuse.org/package/show/home:yuezk/globalprotect-openconnect)|[OBS: globalprotect-openconnect-snapshot](https://build.opensuse.org/package/show/home:yuezk/globalprotect-openconnect-snapshot)|
The CLI version is always free and open source in this repo. It has almost the same features as the GUI version.
Add the repository in the above table and install it with your favorite package manager tool.
```
Usage: gpclient [OPTIONS] <COMMAND>
[![Arch package](https://repology.org/badge/version-for-repo/arch/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![AUR package](https://repology.org/badge/version-for-repo/aur/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![Manjaro Stable package](https://repology.org/badge/version-for-repo/manjaro_stable/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![Manjaro Testing package](https://repology.org/badge/version-for-repo/manjaro_testing/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![Manjaro Unstable package](https://repology.org/badge/version-for-repo/manjaro_unstable/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![nixpkgs unstable package](https://repology.org/badge/version-for-repo/nix_unstable/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
[![Parabola package](https://repology.org/badge/version-for-repo/parabola/globalprotect-openconnect.svg)](https://repology.org/project/globalprotect-openconnect/versions)
Commands:
connect Connect to a portal server
disconnect Disconnect from the server
launch-gui Launch the GUI
help Print this message or the help of the given subcommand(s)
### Linux Mint, Ubuntu 18.04 or later
Options:
--fix-openssl Get around the OpenSSL `unsafe legacy renegotiation` error
--ignore-tls-errors Ignore the TLS errors
-h, --help Print help
-V, --version Print version
```sh
See 'gpclient help <command>' for more information on a specific command.
```
### GUI
The GUI version is also available after you installed it. You can launch it from the application menu or run `gpclient launch-gui` in the terminal.
> [!Note]
>
> The GUI version is partially open source. Its background service is open sourced in this repo as [gpservice](./apps/gpservice/). The GUI part is a wrapper of the background service, which is not open sourced.
## Installation
> [!Note]
>
> This instruction is for the 2.x version. The 1.x version is still available on the [1.x](https://github.com/yuezk/GlobalProtect-openconnect/tree/1.x) branch, you can build it from the source code by following the instructions in the `README.md` file.
> [!Warning]
>
> The client requires `openconnect >= 8.20`, please make sure you have it installed, you can check it with `openconnect --version`.
> Installing the client from PPA will automatically install the required version of `openconnect`.
### Debian/Ubuntu based distributions
#### Install from PPA
```
sudo add-apt-repository ppa:yuezk/globalprotect-openconnect
sudo apt-get update
sudo apt-get install globalprotect-openconnect
```
> [!Note]
>
> For Linux Mint, you might need to import the GPG key with: `sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 7937C393082992E5D6E4A60453FC26B43838D761` if you encountered an error `gpg: keyserver receive failed: General error`.
#### Install from deb package
Download the latest deb package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `dpkg`:
```bash
sudo dpkg -i globalprotect-openconnect_*.deb
```
### Arch Linux / Manjaro
```sh
sudo pacman -S globalprotect-openconnect
#### Install from AUR
Install from AUR: [globalprotect-openconnect-git](https://aur.archlinux.org/packages/globalprotect-openconnect-git/)
```
### AUR snapshot version
```sh
yay -S globalprotect-openconnect-git
```
### Fedora
#### Install from package
```sh
Download the latest package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `pacman`:
```bash
sudo pacman -U globalprotect-openconnect-*.pkg.tar.zst
```
### Fedora/OpenSUSE/CentOS/RHEL
#### Install from COPR
The package is available on [COPR](https://copr.fedorainfracloud.org/coprs/yuezk/globalprotect-openconnect/) for various RPM-based distributions. You can install it with the following commands:
```
sudo dnf copr enable yuezk/globalprotect-openconnect
sudo dnf install globalprotect-openconnect
```
### openSUSE
#### Install from OBS
- openSUSE Tumbleweed
```sh
sudo zypper ar https://download.opensuse.org/repositories/home:/yuezk/openSUSE_Tumbleweed/home:yuezk.repo
sudo zypper ref
sudo zypper install globalprotect-openconnect
```
The package is also available on [OBS](https://build.opensuse.org/package/show/home:yuezk/globalprotect-openconnect) for various RPM-based distributions. You can follow the instructions [on this page](https://software.opensuse.org//download.html?project=home%3Ayuezk&package=globalprotect-openconnect) to install it.
- openSUSE Leap
#### Install from RPM package
```sh
sudo zypper ar https://download.opensuse.org/repositories/home:/yuezk/15.4/home:yuezk.repo
Download the latest RPM package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page.
sudo zypper ref
sudo zypper install globalprotect-openconnect
```
### CentOS 8
### Other distributions
1. Add the repository: `https://download.opensuse.org/repositories/home:/yuezk/CentOS_8/home:yuezk.repo`
1. Install `globalprotect-openconnect`
## Build & Install from source code
Clone this repo with:
```sh
git clone https://github.com/yuezk/GlobalProtect-openconnect.git
cd GlobalProtect-openconnect
```
### MX Linux
The following instructions are for **MX-21.2.1_x64 KDE**.
```sh
sudo apt install qttools5-dev libsecret-1-dev libqt5keychain1
./scripts/install-debian.sh
```
### Ubuntu/Mint
> **⚠️ REQUIRED for Ubuntu 18.04 ⚠️**
>
> Add this [dwmw2/openconnect](https://launchpad.net/~dwmw2/+archive/ubuntu/openconnect) PPA first to install the latest openconnect.
>
> ```sh
> sudo add-apt-repository ppa:dwmw2/openconnect
> sudo apt-get update
> ```
Build and install with:
```sh
./scripts/install-ubuntu.sh
```
### openSUSE
Build and install with:
```sh
./scripts/install-opensuse.sh
```
### Fedora
Build and install with:
```sh
./scripts/install-fedora.sh
```
### Other Linux
Install the Qt5 dependencies and OpenConnect:
- QtCore
- QtWebEngine
- QtWebSockets
- QtDBus
- openconnect v8.x
- qtkeychain
...then build and install with:
```sh
./scripts/install.sh
```
### NixOS
In `configuration.nix`:
```
services.globalprotect = {
enable = true;
# if you need a Host Integrity Protection report
csdWrapper = "${pkgs.openconnect}/libexec/openconnect/hipreport.sh";
};
environment.systemPackages = [ globalprotect-openconnect ];
```
## Run
Once the software is installed, you can run `gpclient` to start the UI.
## Passing the Custom Parameters to `OpenConnect` CLI
See [Configuration](https://github.com/yuezk/GlobalProtect-openconnect/wiki/Configuration)
## Display the system tray icon on Gnome 40
Install the [AppIndicator and KStatusNotifierItem Support](https://extensions.gnome.org/extension/615/appindicator-support/) extension and you will see the system try icon (Restart the system after the installation).
<p align="center">
<img src="https://user-images.githubusercontent.com/3297602/130831022-b93492fd-46dd-4a8e-94a4-13b5747120b7.png" />
<p>
## Troubleshooting
Run `gpclient` in the Terminal and collect the logs.
The project depends on `openconnect >= 8.20`, `webkit2gtk`, `libsecret`, `libayatana-appindicator` or `libappindicator-gtk3`. You can install them first and then download the latest binary release (i.e., `*.bin.tar.gz`) from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page.
## [License](./LICENSE)
GPLv3

View File

@@ -8,7 +8,7 @@ license.workspace = true
tauri-build = { version = "1.5", features = [] }
[dependencies]
gpapi = { path = "../../crates/gpapi", features = ["tauri"] }
gpapi = { path = "../../crates/gpapi", features = ["tauri", "clap"] }
anyhow.workspace = true
clap.workspace = true
env_logger.workspace = true

View File

@@ -7,6 +7,7 @@ use std::{
use anyhow::bail;
use gpapi::{
auth::SamlAuthData,
gp_params::GpParams,
portal::{prelogin, Prelogin},
utils::{redact::redact_uri, window::WindowExt},
};
@@ -18,11 +19,13 @@ use tokio_util::sync::CancellationToken;
use webkit2gtk::{
gio::Cancellable,
glib::{GString, TimeSpan},
LoadEvent, URIResponse, URIResponseExt, WebContextExt, WebResource, WebResourceExt, WebView,
WebViewExt, WebsiteDataManagerExtManual, WebsiteDataTypes,
LoadEvent, SettingsExt, TLSErrorsPolicy, URIResponse, URIResponseExt, WebContextExt, WebResource,
WebResourceExt, WebView, WebViewExt, WebsiteDataManagerExtManual, WebsiteDataTypes,
};
enum AuthDataError {
/// Failed to load page due to TLS error
TlsError,
/// 1. Found auth data in headers/body but it's invalid
/// 2. Loaded an empty page, failed to load page. etc.
Invalid,
@@ -37,6 +40,7 @@ pub(crate) struct AuthWindow<'a> {
server: &'a str,
saml_request: &'a str,
user_agent: &'a str,
gp_params: Option<GpParams>,
clean: bool,
}
@@ -47,6 +51,7 @@ impl<'a> AuthWindow<'a> {
server: "",
saml_request: "",
user_agent: "",
gp_params: None,
clean: false,
}
}
@@ -66,6 +71,11 @@ impl<'a> AuthWindow<'a> {
self
}
pub fn gp_params(mut self, gp_params: GpParams) -> Self {
self.gp_params.replace(gp_params);
self
}
pub fn clean(mut self, clean: bool) -> Self {
self.clean = clean;
self
@@ -76,7 +86,7 @@ impl<'a> AuthWindow<'a> {
let window = Window::builder(&self.app_handle, "auth_window", WindowUrl::default())
.title("GlobalProtect Login")
.user_agent(self.user_agent)
// .user_agent(self.user_agent)
.focused(true)
.visible(false)
.center()
@@ -119,6 +129,12 @@ impl<'a> AuthWindow<'a> {
let saml_request = self.saml_request.to_string();
let (auth_result_tx, mut auth_result_rx) = mpsc::unbounded_channel::<AuthResult>();
let raise_window_cancel_token: Arc<RwLock<Option<CancellationToken>>> = Default::default();
let gp_params = self.gp_params.as_ref().unwrap();
let tls_err_policy = if gp_params.ignore_tls_errors() {
TLSErrorsPolicy::Ignore
} else {
TLSErrorsPolicy::Fail
};
if self.clean {
clear_webview_cookies(window).await?;
@@ -128,6 +144,15 @@ impl<'a> AuthWindow<'a> {
window.with_webview(move |wv| {
let wv = wv.inner();
if let Some(context) = wv.context() {
context.set_tls_errors_policy(tls_err_policy);
}
if let Some(settings) = wv.settings() {
let ua = settings.user_agent().unwrap_or("".into());
info!("Auth window user agent: {}", ua);
}
// Load the initial SAML request
load_saml_request(&wv, &saml_request);
@@ -163,31 +188,37 @@ impl<'a> AuthWindow<'a> {
}
});
wv.connect_load_failed_with_tls_errors(|_wv, uri, cert, err| {
let auth_result_tx_clone = auth_result_tx.clone();
wv.connect_load_failed_with_tls_errors(move |_wv, uri, cert, err| {
let redacted_uri = redact_uri(uri);
warn!(
"Failed to load uri: {} with error: {}, cert: {}",
redacted_uri, err, cert
);
send_auth_result(&auth_result_tx_clone, Err(AuthDataError::TlsError));
true
});
wv.connect_load_failed(move |_wv, _event, uri, err| {
let redacted_uri = redact_uri(uri);
warn!("Failed to load uri: {} with error: {}", redacted_uri, err);
send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
// NOTE: Don't send error here, since load_changed event will be triggered after this
// send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
// true to stop other handlers from being invoked for the event. false to propagate the event further.
true
});
})?;
let portal = self.server.to_string();
let user_agent = self.user_agent.to_string();
loop {
if let Some(auth_result) = auth_result_rx.recv().await {
match auth_result {
Ok(auth_data) => return Ok(auth_data),
Err(AuthDataError::TlsError) => {
return Err(anyhow::anyhow!("TLS error: certificate verify failed"))
}
Err(AuthDataError::NotFound) => {
info!("No auth data found, it may not be the /SAML20/SP/ACS endpoint");
@@ -232,7 +263,7 @@ impl<'a> AuthWindow<'a> {
);
})?;
let saml_request = portal_prelogin(&portal, &user_agent).await?;
let saml_request = portal_prelogin(&portal, gp_params).await?;
window.with_webview(move |wv| {
let wv = wv.inner();
load_saml_request(&wv, &saml_request);
@@ -253,9 +284,10 @@ fn raise_window(window: &Arc<Window>) {
}
}
pub(crate) async fn portal_prelogin(portal: &str, user_agent: &str) -> anyhow::Result<String> {
pub(crate) async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> {
info!("Portal prelogin...");
match prelogin(portal, user_agent).await? {
match prelogin(portal, gp_params).await? {
Prelogin::Saml(prelogin) => Ok(prelogin.saml_request().to_string()),
Prelogin::Standard(_) => Err(anyhow::anyhow!("Received non-SAML prelogin response")),
}
@@ -392,6 +424,11 @@ fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSe
send_auth_result(&auth_result_tx, auth_result)
});
}
Err(AuthDataError::TlsError) => {
// NOTE: This is unreachable
info!("TLS error found in headers, trying to read from body...");
send_auth_result(&auth_result_tx, Err(AuthDataError::TlsError));
}
}
}

View File

@@ -1,6 +1,8 @@
use clap::Parser;
use gpapi::{
auth::{SamlAuthData, SamlAuthResult},
clap::args::Os,
gp_params::{ClientOs, GpParams},
utils::{normalize_server, openssl},
GP_USER_AGENT,
};
@@ -26,23 +28,35 @@ struct Cli {
saml_request: Option<String>,
#[arg(long, default_value = GP_USER_AGENT)]
user_agent: String,
#[arg(long, default_value = "Linux")]
os: Os,
#[arg(long)]
os_version: Option<String>,
#[arg(long)]
hidpi: bool,
#[arg(long)]
fix_openssl: bool,
#[arg(long)]
ignore_tls_errors: bool,
#[arg(long)]
clean: bool,
}
impl Cli {
async fn run(&mut self) -> anyhow::Result<()> {
if self.ignore_tls_errors {
info!("TLS errors will be ignored");
}
let mut openssl_conf = self.prepare_env()?;
self.server = normalize_server(&self.server)?;
let gp_params = self.build_gp_params();
// Get the initial SAML request
let saml_request = match self.saml_request {
Some(ref saml_request) => saml_request.clone(),
None => portal_prelogin(&self.server, &self.user_agent).await?,
None => portal_prelogin(&self.server, &gp_params).await?,
};
self.saml_request.replace(saml_request);
@@ -82,10 +96,22 @@ impl Cli {
Ok(None)
}
fn build_gp_params(&self) -> GpParams {
let gp_params = GpParams::builder()
.user_agent(&self.user_agent)
.client_os(ClientOs::from(&self.os))
.os_version(self.os_version.clone())
.ignore_tls_errors(self.ignore_tls_errors)
.build();
gp_params
}
async fn saml_auth(&self, app_handle: AppHandle) -> anyhow::Result<SamlAuthData> {
let auth_window = AuthWindow::new(app_handle)
.server(&self.server)
.user_agent(&self.user_agent)
.gp_params(self.build_gp_params())
.saml_request(self.saml_request.as_ref().unwrap())
.clean(self.clean);

View File

@@ -6,7 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
gpapi = { path = "../../crates/gpapi" }
gpapi = { path = "../../crates/gpapi", features = ["clap"] }
openconnect = { path = "../../crates/openconnect" }
anyhow.workspace = true
clap.workspace = true

View File

@@ -16,6 +16,11 @@ const VERSION: &str = concat!(
")"
);
pub(crate) struct SharedArgs {
pub(crate) fix_openssl: bool,
pub(crate) ignore_tls_errors: bool,
}
#[derive(Subcommand)]
enum CliCommand {
#[command(about = "Connect to a portal server")]
@@ -40,6 +45,8 @@ enum CliCommand {
{usage-heading} {usage}
{all-args}{after-help}
See 'gpclient help <command>' for more information on a specific command.
"
)]
struct Cli {
@@ -51,6 +58,8 @@ struct Cli {
help = "Get around the OpenSSL `unsafe legacy renegotiation` error"
)]
fix_openssl: bool,
#[arg(long, help = "Ignore the TLS errors")]
ignore_tls_errors: bool,
}
impl Cli {
@@ -67,9 +76,17 @@ impl Cli {
// The temp file will be dropped automatically when the file handle is dropped
// So, declare it here to ensure it's not dropped
let _file = self.fix_openssl()?;
let shared_args = SharedArgs {
fix_openssl: self.fix_openssl,
ignore_tls_errors: self.ignore_tls_errors,
};
if self.ignore_tls_errors {
info!("TLS errors will be ignored");
}
match &self.command {
CliCommand::Connect(args) => ConnectHandler::new(args, self.fix_openssl).handle().await,
CliCommand::Connect(args) => ConnectHandler::new(args, &shared_args).handle().await,
CliCommand::Disconnect => DisconnectHandler::new().handle(),
CliCommand::LaunchGui(args) => LaunchGuiHandler::new(args).handle().await,
}
@@ -89,13 +106,24 @@ pub(crate) async fn run() {
if let Err(err) = cli.run().await {
eprintln!("\nError: {}", err);
if err.to_string().contains("unsafe legacy renegotiation") && !cli.fix_openssl {
let err = err.to_string();
if err.contains("unsafe legacy renegotiation") && !cli.fix_openssl {
eprintln!("\nRe-run it with the `--fix-openssl` option to work around this issue, e.g.:\n");
// Print the command
let args = std::env::args().collect::<Vec<_>>();
eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" "));
}
if err.contains("certificate verify failed") && !cli.ignore_tls_errors {
eprintln!(
"\nRe-run it with the `--ignore-tls-errors` option to ignore the certificate error, e.g.:\n"
);
// Print the command
let args = std::env::args().collect::<Vec<_>>();
eprintln!("{} --ignore-tls-errors {}\n", args[0], args[1..].join(" "));
}
std::process::exit(1);
}
}

View File

@@ -2,9 +2,10 @@ use std::{fs, sync::Arc};
use clap::Args;
use gpapi::{
clap::args::Os,
credential::{Credential, PasswordCredential},
gateway::gateway_login,
gp_params::GpParams,
gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, Prelogin},
process::auth_launcher::SamlAuthLauncher,
utils::{self, shutdown_signal},
@@ -14,7 +15,7 @@ use inquire::{Password, PasswordDisplayMode, Select, Text};
use log::info;
use openconnect::Vpn;
use crate::GP_CLIENT_LOCK_FILE;
use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE};
#[derive(Args)]
pub(crate) struct ConnectArgs {
@@ -36,31 +37,55 @@ pub(crate) struct ConnectArgs {
script: Option<String>,
#[arg(long, default_value = GP_USER_AGENT, help = "The user agent to use")]
user_agent: String,
#[arg(long, default_value = "Linux")]
os: Os,
#[arg(long)]
os_version: Option<String>,
#[arg(long, help = "The HiDPI mode, useful for high resolution screens")]
hidpi: bool,
#[arg(long, help = "Do not reuse the remembered authentication cookie")]
clean: bool,
}
impl ConnectArgs {
fn os_version(&self) -> String {
if let Some(os_version) = &self.os_version {
return os_version.to_owned();
}
match self.os {
Os::Linux => format!("Linux {}", whoami::distro()),
Os::Windows => String::from("Microsoft Windows 11 Pro , 64-bit"),
Os::Mac => String::from("Apple Mac OS X 13.4.0"),
}
}
}
pub(crate) struct ConnectHandler<'a> {
args: &'a ConnectArgs,
fix_openssl: bool,
shared_args: &'a SharedArgs,
}
impl<'a> ConnectHandler<'a> {
pub(crate) fn new(args: &'a ConnectArgs, fix_openssl: bool) -> Self {
Self { args, fix_openssl }
pub(crate) fn new(args: &'a ConnectArgs, shared_args: &'a SharedArgs) -> Self {
Self { args, shared_args }
}
fn build_gp_params(&self) -> GpParams {
GpParams::builder()
.user_agent(&self.args.user_agent)
.client_os(ClientOs::from(&self.args.os))
.os_version(self.args.os_version())
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.build()
}
pub(crate) async fn handle(&self) -> anyhow::Result<()> {
let portal = utils::normalize_server(self.args.server.as_str())?;
let gp_params = self.build_gp_params();
let gp_params = GpParams::builder()
.user_agent(&self.args.user_agent)
.build();
let prelogin = prelogin(&portal, &self.args.user_agent).await?;
let portal_credential = self.obtain_portal_credential(&prelogin).await?;
let prelogin = prelogin(&portal, &gp_params).await?;
let portal_credential = self.obtain_credential(&prelogin).await?;
let mut portal_config = retrieve_config(&portal, &portal_credential, &gp_params).await?;
let selected_gateway = match &self.args.gateway {
@@ -83,7 +108,14 @@ impl<'a> ConnectHandler<'a> {
let gateway = selected_gateway.server();
let cred = portal_config.auth_cookie().into();
let token = gateway_login(gateway, &cred, &gp_params).await?;
let token = match gateway_login(gateway, &cred, &gp_params).await {
Ok(token) => token,
Err(_) => {
info!("Gateway login failed, retrying with prelogin");
self.gateway_login_with_prelogin(gateway).await?
}
};
let vpn = Vpn::builder(gateway, &token)
.user_agent(self.args.user_agent.clone())
@@ -110,14 +142,27 @@ impl<'a> ConnectHandler<'a> {
Ok(())
}
async fn obtain_portal_credential(&self, prelogin: &Prelogin) -> anyhow::Result<Credential> {
async fn gateway_login_with_prelogin(&self, gateway: &str) -> anyhow::Result<String> {
let mut gp_params = self.build_gp_params();
gp_params.set_is_gateway(true);
let prelogin = prelogin(gateway, &gp_params).await?;
let cred = self.obtain_credential(&prelogin).await?;
gateway_login(gateway, &cred, &gp_params).await
}
async fn obtain_credential(&self, prelogin: &Prelogin) -> anyhow::Result<Credential> {
match prelogin {
Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server)
.user_agent(&self.args.user_agent)
.saml_request(prelogin.saml_request())
.user_agent(&self.args.user_agent)
.os(self.args.os.as_str())
.os_version(Some(&self.args.os_version()))
.hidpi(self.args.hidpi)
.fix_openssl(self.fix_openssl)
.fix_openssl(self.shared_args.fix_openssl)
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.clean(self.args.clean)
.launch()
.await

View File

@@ -10,7 +10,12 @@ use log::info;
#[derive(Args)]
pub(crate) struct LaunchGuiArgs {
#[clap(long, help = "Launch the GUI minimized")]
#[arg(
required = false,
help = "The authentication data, used for the default browser authentication"
)]
auth_data: Option<String>,
#[arg(long, help = "Launch the GUI minimized")]
minimized: bool,
}
@@ -30,6 +35,12 @@ impl<'a> LaunchGuiHandler<'a> {
anyhow::bail!("`launch-gui` cannot be run as root");
}
let auth_data = self.args.auth_data.as_deref().unwrap_or_default();
if !auth_data.is_empty() {
// Process the authentication data, its format is `globalprotectcallback:<data>`
return feed_auth_data(auth_data).await;
}
if try_active_gui().await.is_ok() {
info!("The GUI is already running");
return Ok(());
@@ -66,6 +77,19 @@ impl<'a> LaunchGuiHandler<'a> {
}
}
async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;
reqwest::Client::default()
.post(format!("{}/auth-data", service_endpoint))
.json(&auth_data)
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn try_active_gui() -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>GlobalProtect-openconnect</vendor>
<vendor_url>https://github.com/yuezk/GlobalProtect-openconnect</vendor_url>
<icon_name>gpgui</icon_name>
<action id="com.yuezk.gpservice">
<description>Run GPService as root</description>
<message>Authentication is required to run the GPService as root</message>
<defaults>
<allow_any>yes</allow_any>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/home/kevin/Documents/repos/gp/target/debug/gpservice</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">--with-gui</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
</action>
</policyconfig>

View File

@@ -21,6 +21,13 @@ pub(crate) async fn active_gui(State(ctx): State<Arc<WsServerContext>>) -> impl
ctx.send_event(WsEvent::ActiveGui).await;
}
pub(crate) async fn auth_data(
State(ctx): State<Arc<WsServerContext>>,
body: String,
) -> impl IntoResponse {
ctx.send_event(WsEvent::AuthData(body)).await;
}
pub(crate) async fn ws_handler(
ws: WebSocketUpgrade,
State(ctx): State<Arc<WsServerContext>>,

View File

@@ -8,6 +8,7 @@ pub(crate) fn routes(ctx: Arc<WsServerContext>) -> Router {
Router::new()
.route("/health", get(handlers::health))
.route("/active-gui", post(handlers::active_gui))
.route("/auth-data", post(handlers::auth_data))
.route("/ws", get(handlers::ws_handler))
.with_state(ctx)
}

View File

@@ -24,9 +24,13 @@ redact-engine.workspace = true
url.workspace = true
regex.workspace = true
dotenvy_macro.workspace = true
users.workspace = true
uzers.workspace = true
tauri = { workspace = true, optional = true }
clap = { workspace = true, optional = true }
open = { version = "5", optional = true }
[features]
tauri = ["dep:tauri"]
clap = ["dep:clap"]
browser-auth = ["dep:open"]

View File

@@ -1,3 +1,5 @@
use anyhow::bail;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
@@ -37,6 +39,32 @@ impl SamlAuthData {
}
}
pub fn parse_html(html: &str) -> anyhow::Result<SamlAuthData> {
match parse_xml_tag(html, "saml-auth-status") {
Some(saml_status) if saml_status == "1" => {
let username = parse_xml_tag(html, "saml-username");
let prelogin_cookie = parse_xml_tag(html, "prelogin-cookie");
let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie");
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
return Ok(SamlAuthData::new(
username.unwrap(),
prelogin_cookie,
portal_userauthcookie,
));
}
bail!("Found invalid auth data in HTML");
}
Some(status) => {
bail!("Found invalid SAML status {} in HTML", status);
}
None => {
bail!("No auth data found in HTML");
}
}
}
pub fn username(&self) -> &str {
&self.username
}
@@ -61,3 +89,10 @@ impl SamlAuthData {
username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid)
}
}
pub fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
let re = Regex::new(&format!("<{}>(.*)</{}>", tag, tag)).unwrap();
re.captures(html)
.and_then(|captures| captures.get(1))
.map(|m| m.as_str().to_string())
}

View File

@@ -0,0 +1,64 @@
use clap::{builder::PossibleValue, ValueEnum};
use crate::gp_params::ClientOs;
#[derive(Debug, Clone)]
pub enum Os {
Linux,
Windows,
Mac,
}
impl Os {
pub fn as_str(&self) -> &'static str {
match self {
Os::Linux => "Linux",
Os::Windows => "Windows",
Os::Mac => "Mac",
}
}
}
impl From<&str> for Os {
fn from(os: &str) -> Self {
match os.to_lowercase().as_str() {
"linux" => Os::Linux,
"windows" => Os::Windows,
"mac" => Os::Mac,
_ => Os::Linux,
}
}
}
impl From<&Os> for ClientOs {
fn from(value: &Os) -> Self {
match value {
Os::Linux => ClientOs::Linux,
Os::Windows => ClientOs::Windows,
Os::Mac => ClientOs::Mac,
}
}
}
impl ValueEnum for Os {
fn value_variants<'a>() -> &'a [Self] {
&[Os::Linux, Os::Windows, Os::Mac]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
match self {
Os::Linux => Some(PossibleValue::new("Linux")),
Os::Windows => Some(PossibleValue::new("Windows")),
Os::Mac => Some(PossibleValue::new("Mac")),
}
}
fn from_str(input: &str, _: bool) -> Result<Self, String> {
match input.to_lowercase().as_str() {
"linux" => Ok(Os::Linux),
"windows" => Ok(Os::Windows),
"mac" => Ok(Os::Mac),
_ => Err(format!("Invalid OS: {}", input)),
}
}
}

View File

@@ -0,0 +1 @@
pub mod args;

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::auth::SamlAuthData;
use crate::{auth::SamlAuthData, utils::base64::decode_to_string};
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
@@ -151,6 +151,17 @@ pub enum Credential {
}
impl Credential {
/// Create a credential from a globalprotectcallback:<base64 encoded string>
pub fn parse_gpcallback(auth_data: &str) -> anyhow::Result<Self> {
// Remove the surrounding quotes
let auth_data = auth_data.trim_matches('"');
let auth_data = auth_data.trim_start_matches("globalprotectcallback:");
let auth_data = decode_to_string(auth_data)?;
let auth_data = SamlAuthData::parse_html(&auth_data)?;
Self::try_from(auth_data)
}
pub fn username(&self) -> &str {
match self {
Credential::Password(cred) => cred.username(),
@@ -164,31 +175,34 @@ impl Credential {
let mut params = HashMap::new();
params.insert("user", self.username());
match self {
Credential::Password(cred) => {
params.insert("passwd", cred.password());
}
Credential::PreloginCookie(cred) => {
params.insert("prelogin-cookie", cred.prelogin_cookie());
}
Credential::AuthCookie(cred) => {
params.insert("portal-userauthcookie", cred.user_auth_cookie());
let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie) = match self
{
Credential::Password(cred) => (Some(cred.password()), None, None, None),
Credential::PreloginCookie(cred) => (None, Some(cred.prelogin_cookie()), None, None),
Credential::AuthCookie(cred) => (
None,
None,
Some(cred.user_auth_cookie()),
Some(cred.prelogon_user_auth_cookie()),
),
Credential::CachedCredential(cred) => (
cred.password(),
None,
Some(cred.auth_cookie.user_auth_cookie()),
Some(cred.auth_cookie.prelogon_user_auth_cookie()),
),
};
params.insert("passwd", passwd.unwrap_or_default());
params.insert("prelogin-cookie", prelogin_cookie.unwrap_or_default());
params.insert(
"portal-userauthcookie",
portal_userauthcookie.unwrap_or_default(),
);
params.insert(
"portal-prelogonuserauthcookie",
cred.prelogon_user_auth_cookie(),
portal_prelogonuserauthcookie.unwrap_or_default(),
);
}
Credential::CachedCredential(cred) => {
if let Some(password) = cred.password() {
params.insert("passwd", password);
}
params.insert("portal-userauthcookie", cred.auth_cookie.user_auth_cookie());
params.insert(
"portal-prelogonuserauthcookie",
cred.auth_cookie.prelogon_user_auth_cookie(),
);
}
}
params
}

View File

@@ -12,6 +12,7 @@ pub async fn gateway_login(
) -> anyhow::Result<String> {
let login_url = format!("https://{}/ssl-vpn/login.esp", gateway);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
@@ -23,14 +24,8 @@ pub async fn gateway_login(
info!("Gateway login, user_agent: {}", gp_params.user_agent());
let res_xml = client
.post(&login_url)
.form(&params)
.send()
.await?
.error_for_status()?
.text()
.await?;
let res = client.post(&login_url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
let doc = Document::parse(&res_xml)?;

View File

@@ -7,23 +7,32 @@ use crate::GP_USER_AGENT;
#[derive(Debug, Serialize, Deserialize, Clone, Type, Default)]
pub enum ClientOs {
Linux,
#[default]
Linux,
Windows,
Mac,
}
impl From<&ClientOs> for &str {
fn from(os: &ClientOs) -> Self {
impl From<&str> for ClientOs {
fn from(os: &str) -> Self {
match os {
ClientOs::Linux => "Linux",
ClientOs::Windows => "Windows",
ClientOs::Mac => "Mac",
"Linux" => ClientOs::Linux,
"Windows" => ClientOs::Windows,
"Mac" => ClientOs::Mac,
_ => ClientOs::Linux,
}
}
}
impl ClientOs {
pub fn as_str(&self) -> &str {
match self {
ClientOs::Linux => "Linux",
ClientOs::Windows => "Windows",
ClientOs::Mac => "Mac",
}
}
pub fn to_openconnect_os(&self) -> &str {
match self {
ClientOs::Linux => "linux",
@@ -35,11 +44,14 @@ impl ClientOs {
#[derive(Debug, Serialize, Deserialize, Type, Default)]
pub struct GpParams {
is_gateway: bool,
user_agent: String,
client_os: ClientOs,
os_version: Option<String>,
client_version: Option<String>,
computer: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
}
impl GpParams {
@@ -47,20 +59,33 @@ impl GpParams {
GpParamsBuilder::new()
}
pub(crate) fn is_gateway(&self) -> bool {
self.is_gateway
}
pub fn set_is_gateway(&mut self, is_gateway: bool) {
self.is_gateway = is_gateway;
}
pub(crate) fn user_agent(&self) -> &str {
&self.user_agent
}
pub(crate) fn computer(&self) -> &str {
match self.computer {
Some(ref computer) => computer,
None => (&self.client_os).into()
&self.computer
}
pub fn ignore_tls_errors(&self) -> bool {
self.ignore_tls_errors
}
pub fn prefer_default_browser(&self) -> bool {
self.prefer_default_browser
}
pub(crate) fn to_params(&self) -> HashMap<&str, &str> {
let mut params: HashMap<&str, &str> = HashMap::new();
let client_os: &str = (&self.client_os).into();
let client_os = self.client_os.as_str();
// Common params
params.insert("prot", "https:");
@@ -70,46 +95,52 @@ impl GpParams {
params.insert("ipv6-support", "yes");
params.insert("inputStr", "");
params.insert("clientVer", "4100");
params.insert("clientos", client_os);
if let Some(computer) = &self.computer {
params.insert("computer", computer);
} else {
params.insert("computer", client_os);
}
params.insert("computer", &self.computer);
if let Some(os_version) = &self.os_version {
params.insert("os-version", os_version);
}
if let Some(client_version) = &self.client_version {
params.insert("clientgpversion", client_version);
}
// NOTE: Do not include clientgpversion for now
// if let Some(client_version) = &self.client_version {
// params.insert("clientgpversion", client_version);
// }
params
}
}
pub struct GpParamsBuilder {
is_gateway: bool,
user_agent: String,
client_os: ClientOs,
os_version: Option<String>,
client_version: Option<String>,
computer: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
}
impl GpParamsBuilder {
pub fn new() -> Self {
Self {
is_gateway: false,
user_agent: GP_USER_AGENT.to_string(),
client_os: ClientOs::Linux,
os_version: Default::default(),
client_version: Default::default(),
computer: Default::default(),
computer: whoami::hostname(),
ignore_tls_errors: false,
prefer_default_browser: false,
}
}
pub fn is_gateway(&mut self, is_gateway: bool) -> &mut Self {
self.is_gateway = is_gateway;
self
}
pub fn user_agent(&mut self, user_agent: &str) -> &mut Self {
self.user_agent = user_agent.to_string();
self
@@ -120,28 +151,41 @@ impl GpParamsBuilder {
self
}
pub fn os_version(&mut self, os_version: &str) -> &mut Self {
self.os_version = Some(os_version.to_string());
pub fn os_version<T: Into<Option<String>>>(&mut self, os_version: T) -> &mut Self {
self.os_version = os_version.into();
self
}
pub fn client_version(&mut self, client_version: &str) -> &mut Self {
self.client_version = Some(client_version.to_string());
pub fn client_version<T: Into<Option<String>>>(&mut self, client_version: T) -> &mut Self {
self.client_version = client_version.into();
self
}
pub fn computer(&mut self, computer: &str) -> &mut Self {
self.computer = Some(computer.to_string());
self.computer = computer.to_string();
self
}
pub fn ignore_tls_errors(&mut self, ignore_tls_errors: bool) -> &mut Self {
self.ignore_tls_errors = ignore_tls_errors;
self
}
pub fn prefer_default_browser(&mut self, prefer_default_browser: bool) -> &mut Self {
self.prefer_default_browser = prefer_default_browser;
self
}
pub fn build(&self) -> GpParams {
GpParams {
is_gateway: self.is_gateway,
user_agent: self.user_agent.clone(),
client_os: self.client_os.clone(),
os_version: self.os_version.clone(),
client_version: self.client_version.clone(),
computer: self.computer.clone(),
ignore_tls_errors: self.ignore_tls_errors,
prefer_default_browser: self.prefer_default_browser,
}
}
}

View File

@@ -7,12 +7,17 @@ pub mod process;
pub mod service;
pub mod utils;
#[cfg(feature = "clap")]
pub mod clap;
#[cfg(debug_assertions)]
pub const GP_API_KEY: &[u8; 32] = &[0; 32];
pub const GP_USER_AGENT: &str = "PAN GlobalProtect";
pub const GP_SERVICE_LOCK_FILE: &str = "/var/run/gpservice.lock";
#[cfg(not(debug_assertions))]
pub const GP_CLIENT_BINARY: &str = "/usr/bin/gpclient";
#[cfg(not(debug_assertions))]
pub const GP_SERVICE_BINARY: &str = "/usr/bin/gpservice";
#[cfg(not(debug_assertions))]
@@ -20,6 +25,8 @@ pub const GP_GUI_BINARY: &str = "/usr/bin/gpgui";
#[cfg(not(debug_assertions))]
pub(crate) const GP_AUTH_BINARY: &str = "/usr/bin/gpauth";
#[cfg(debug_assertions)]
pub const GP_CLIENT_BINARY: &str = dotenvy_macro::dotenv!("GP_CLIENT_BINARY");
#[cfg(debug_assertions)]
pub const GP_SERVICE_BINARY: &str = dotenvy_macro::dotenv!("GP_SERVICE_BINARY");
#[cfg(debug_assertions)]

View File

@@ -102,12 +102,6 @@ impl PortalConfig {
pub enum PortalConfigError {
#[error("Empty response, retrying can help")]
EmptyResponse,
#[error("Empty auth cookie, retrying can help")]
EmptyAuthCookie,
#[error("Invalid auth cookie, retrying can help")]
InvalidAuthCookie,
#[error("Empty gateways, retrying can help")]
EmptyGateways,
}
pub async fn retrieve_config(
@@ -120,6 +114,7 @@ pub async fn retrieve_config(
let url = format!("{}/global-protect/getconfig.esp", portal);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
@@ -132,36 +127,28 @@ pub async fn retrieve_config(
info!("Portal config, user_agent: {}", gp_params.user_agent());
let res_xml = client
.post(&url)
.form(&params)
.send()
.await?
.error_for_status()?
.text()
.await?;
let res = client.post(&url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
ensure!(!res_xml.is_empty(), PortalConfigError::EmptyResponse);
let doc = Document::parse(&res_xml)?;
let gateways = parse_gateways(&doc).ok_or_else(|| anyhow::anyhow!("Failed to parse gateways"))?;
let mut gateways =
parse_gateways(&doc).ok_or_else(|| anyhow::anyhow!("Failed to parse gateways"))?;
let user_auth_cookie = xml::get_child_text(&doc, "portal-userauthcookie").unwrap_or_default();
let prelogon_user_auth_cookie =
xml::get_child_text(&doc, "portal-prelogonuserauthcookie").unwrap_or_default();
let config_digest = xml::get_child_text(&doc, "config-digest");
ensure!(
!user_auth_cookie.is_empty() && !prelogon_user_auth_cookie.is_empty(),
PortalConfigError::EmptyAuthCookie
);
ensure!(
user_auth_cookie != "empty" && prelogon_user_auth_cookie != "empty",
PortalConfigError::InvalidAuthCookie
);
ensure!(!gateways.is_empty(), PortalConfigError::EmptyGateways);
if gateways.is_empty() {
gateways.push(Gateway {
name: server.to_string(),
address: server.to_string(),
priority: 0,
priority_rules: vec![],
});
}
Ok(PortalConfig::new(
server.to_string(),

View File

@@ -5,13 +5,28 @@ use roxmltree::Document;
use serde::Serialize;
use specta::Type;
use crate::utils::{base64, normalize_server, xml};
use crate::{
gp_params::GpParams,
utils::{base64, normalize_server, xml},
};
const REQUIRED_PARAMS: [&str; 8] = [
"tmp",
"clientVer",
"clientos",
"os-version",
"host-id",
"ipv6-support",
"default-browser",
"cas-support",
];
#[derive(Debug, Serialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SamlPrelogin {
region: String,
saml_request: String,
support_default_browser: bool,
}
impl SamlPrelogin {
@@ -22,6 +37,10 @@ impl SamlPrelogin {
pub fn saml_request(&self) -> &str {
&self.saml_request
}
pub fn support_default_browser(&self) -> bool {
self.support_default_browser
}
}
#[derive(Debug, Serialize, Type, Clone)]
@@ -67,20 +86,39 @@ impl Prelogin {
}
}
pub async fn prelogin(portal: &str, user_agent: &str) -> anyhow::Result<Prelogin> {
pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> {
let user_agent = gp_params.user_agent();
info!("Portal prelogin, user_agent: {}", user_agent);
let portal = normalize_server(portal)?;
let prelogin_url = format!("{}/global-protect/prelogin.esp", portal);
let client = Client::builder().user_agent(user_agent).build()?;
let prelogin_url = format!(
"{portal}/{}/prelogin.esp",
if gp_params.is_gateway() {
"ssl-vpn"
} else {
"global-protect"
}
);
let mut params = gp_params.to_params();
let res_xml = client
.get(&prelogin_url)
.send()
.await?
.error_for_status()?
.text()
.await?;
params.insert("tmp", "tmp");
if gp_params.prefer_default_browser() {
params.insert("default-browser", "1");
}
params.retain(|k, _| {
REQUIRED_PARAMS
.iter()
.any(|required_param| required_param == k)
});
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(user_agent)
.build()?;
let res = client.post(&prelogin_url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
trace!("Prelogin response: {}", res_xml);
let doc = Document::parse(&res_xml)?;
@@ -98,12 +136,18 @@ pub async fn prelogin(portal: &str, user_agent: &str) -> anyhow::Result<Prelogin
let saml_method = xml::get_child_text(&doc, "saml-auth-method");
let saml_request = xml::get_child_text(&doc, "saml-request");
let saml_default_browser = xml::get_child_text(&doc, "saml-default-browser");
// Check if the prelogin response is SAML
if saml_method.is_some() && saml_request.is_some() {
let saml_request = base64::decode_to_string(&saml_request.unwrap())?;
let support_default_browser = saml_default_browser
.map(|s| s.to_lowercase() == "yes")
.unwrap_or(false);
let saml_prelogin = SamlPrelogin {
region,
saml_request,
support_default_browser,
};
return Ok(Prelogin::Saml(saml_prelogin));

View File

@@ -8,10 +8,13 @@ use super::command_traits::CommandExt;
pub struct SamlAuthLauncher<'a> {
server: &'a str,
user_agent: Option<&'a str>,
saml_request: Option<&'a str>,
user_agent: Option<&'a str>,
os: Option<&'a str>,
os_version: Option<&'a str>,
hidpi: bool,
fix_openssl: bool,
ignore_tls_errors: bool,
clean: bool,
}
@@ -19,21 +22,34 @@ impl<'a> SamlAuthLauncher<'a> {
pub fn new(server: &'a str) -> Self {
Self {
server,
user_agent: None,
saml_request: None,
user_agent: None,
os: None,
os_version: None,
hidpi: false,
fix_openssl: false,
ignore_tls_errors: false,
clean: false,
}
}
pub fn saml_request(mut self, saml_request: &'a str) -> Self {
self.saml_request = Some(saml_request);
self
}
pub fn user_agent(mut self, user_agent: &'a str) -> Self {
self.user_agent = Some(user_agent);
self
}
pub fn saml_request(mut self, saml_request: &'a str) -> Self {
self.saml_request = Some(saml_request);
pub fn os(mut self, os: &'a str) -> Self {
self.os = Some(os);
self
}
pub fn os_version(mut self, os_version: Option<&'a str>) -> Self {
self.os_version = os_version;
self
}
@@ -47,6 +63,11 @@ impl<'a> SamlAuthLauncher<'a> {
self
}
pub fn ignore_tls_errors(mut self, ignore_tls_errors: bool) -> Self {
self.ignore_tls_errors = ignore_tls_errors;
self
}
pub fn clean(mut self, clean: bool) -> Self {
self.clean = clean;
self
@@ -57,20 +78,32 @@ impl<'a> SamlAuthLauncher<'a> {
let mut auth_cmd = Command::new(GP_AUTH_BINARY);
auth_cmd.arg(self.server);
if let Some(saml_request) = self.saml_request {
auth_cmd.arg("--saml-request").arg(saml_request);
}
if let Some(user_agent) = self.user_agent {
auth_cmd.arg("--user-agent").arg(user_agent);
}
if let Some(saml_request) = self.saml_request {
auth_cmd.arg("--saml-request").arg(saml_request);
if let Some(os) = self.os {
auth_cmd.arg("--os").arg(os);
}
if let Some(os_version) = self.os_version {
auth_cmd.arg("--os-version").arg(os_version);
}
if self.hidpi {
auth_cmd.arg("--hidpi");
}
if self.fix_openssl {
auth_cmd.arg("--fix-openssl");
}
if self.hidpi {
auth_cmd.arg("--hidpi");
if self.ignore_tls_errors {
auth_cmd.arg("--ignore-tls-errors");
}
if self.clean {

View File

@@ -0,0 +1,34 @@
use std::{env::temp_dir, io::Write};
pub struct BrowserAuthenticator<'a> {
auth_request: &'a str,
}
impl BrowserAuthenticator<'_> {
pub fn new(auth_request: &str) -> BrowserAuthenticator {
BrowserAuthenticator { auth_request }
}
pub fn authenticate(&self) -> anyhow::Result<()> {
if self.auth_request.starts_with("http") {
open::that_detached(self.auth_request)?;
} else {
let html_file = temp_dir().join("gpauth.html");
let mut file = std::fs::File::create(&html_file)?;
file.write_all(self.auth_request.as_bytes())?;
open::that_detached(html_file)?;
}
Ok(())
}
}
impl Drop for BrowserAuthenticator<'_> {
fn drop(&mut self) {
// Cleanup the temporary file
let html_file = temp_dir().join("gpauth.html");
let _ = std::fs::remove_file(html_file);
}
}

View File

@@ -1,7 +1,7 @@
use anyhow::bail;
use std::{env, ffi::OsStr};
use tokio::process::Command;
use users::{os::unix::UserExt, User};
use uzers::{os::unix::UserExt, User};
pub trait CommandExt {
fn new_pkexec<S: AsRef<OsStr>>(program: S) -> Command;
@@ -42,7 +42,7 @@ fn get_non_root_user() -> anyhow::Result<User> {
let user = if current_user == "root" {
get_real_user()?
} else {
users::get_user_by_name(&current_user)
uzers::get_user_by_name(&current_user)
.ok_or_else(|| anyhow::anyhow!("User ({}) not found", current_user))?
};
@@ -60,5 +60,5 @@ fn get_real_user() -> anyhow::Result<User> {
_ => env::var("PKEXEC_UID")?.parse::<u32>()?,
};
users::get_user_by_uid(uid).ok_or_else(|| anyhow::anyhow!("User not found"))
uzers::get_user_by_uid(uid).ok_or_else(|| anyhow::anyhow!("User not found"))
}

View File

@@ -1,5 +1,7 @@
pub(crate) mod command_traits;
pub mod auth_launcher;
#[cfg(feature = "browser-auth")]
pub mod browser_authenticator;
pub mod gui_launcher;
pub mod service_launcher;

View File

@@ -7,4 +7,6 @@ use super::vpn_state::VpnState;
pub enum WsEvent {
VpnState(VpnState),
ActiveGui,
/// External authentication data
AuthData(String),
}

View File

@@ -27,7 +27,6 @@ pub fn raise_window(win: &Window) -> anyhow::Result<()> {
}
let title = win.title()?;
tokio::spawn(async move {
info!("Raising window: {}", title);
if let Err(err) = wmctrl_raise_window(&title).await {
warn!("Failed to raise window: {}", err);
}