Compare commits

..

18 Commits

Author SHA1 Message Date
Kevin Yue
1f50e4d82b Add CI 2024-01-28 20:34:15 +08:00
Kevin Yue
995d1216ea Bump version 2.0.0-beta8 2024-01-28 20:21:33 +08:00
Kevin Yue
196e91289c Update format 2024-01-28 05:11:46 -05:00
Kevin Yue
b2bb35994f Support connect gateway (#306) 2024-01-28 11:41:48 +08:00
Kevin Yue
6fe6a1387a Update README.md 2024-01-25 20:30:23 +08:00
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
46 changed files with 1025 additions and 590 deletions

View File

@@ -8,8 +8,9 @@ on:
- .devcontainer
branches:
- main
# tags:
# - v*.*.*
tags:
- latest
- v*.*.*
jobs:
# Include arm64 if ref is a tag
setup-matrix:
@@ -30,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
@@ -54,43 +55,35 @@ jobs:
pnpm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: gpgui-fe
path: app/dist
build-tauri:
needs: [setup-matrix, build-fe]
build-tauri-amd64:
needs: [build-fe]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Checkout gp repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
path: gp
- name: Download gpgui-fe artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: gpgui-fe
path: gpgui/app/dist
- 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:
@@ -104,35 +97,103 @@ jobs:
-v $(pwd):/${{ github.workspace }} \
-w ${{ github.workspace }} \
-e CI=true \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:main \
"./gpgui/scripts/build.sh"
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-tauri
name: artifact-amd64-tauri
path: |
gpgui/.tmp/artifact
build-tauri-arm64:
if: startsWith(github.ref, 'refs/tags/')
needs: [build-fe]
runs-on: self-hosted
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Checkout gp repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
path: gp
- name: Download gpgui-fe artifact
uses: actions/download-artifact@v3
with:
name: gpgui-fe
path: gpgui/app/dist
- name: Build Tauri
run: |
./gpgui/scripts/build.sh
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: artifact-arm64-tauri
path: |
gpgui/.tmp/artifact
package-tarball:
needs: [build-tauri-amd64, build-tauri-arm64]
runs-on: ubuntu-latest
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-amd64-tauri
uses: actions/download-artifact@v3
with:
name: artifact-amd64-tauri
path: gpgui/.tmp/artifact
- name: Download artifact-arm64-tauri
uses: actions/download-artifact@v3
with:
name: artifact-arm64-tauri
path: gpgui/.tmp/artifact
- name: Create tarball
run: |
./gpgui/scripts/build-tarball.sh
- name: Upload tarball
uses: actions/upload-artifact@v3
with:
name: artifact-tarball
path: |
gpgui/.tmp/tarball/*.tar.gz
package-rpm:
needs: [setup-matrix, build-tauri]
needs: [setup-matrix, package-tarball]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v4
- name: Download package tarball
uses: actions/download-artifact@v3
with:
name: artifact-${{ matrix.arch }}-tauri
name: artifact-tarball
path: gpgui/.tmp/artifact
- name: Set up QEMU
@@ -157,28 +218,28 @@ jobs:
"./gpgui/scripts/build-rpm.sh"
- name: Upload rpm artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-rpm
path: |
gpgui/.tmp/artifact/*.rpm
package-pkgbuild:
needs: [setup-matrix, build-tauri]
needs: [setup-matrix, build-tauri-amd64, build-tauri-arm64]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: artifact-${{ matrix.arch }}-tauri
path: gpgui/.tmp/artifact
@@ -196,13 +257,11 @@ jobs:
- name: Generate PKGBUILD
run: |
export CI_ARCH=${{ matrix.arch }}
./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 \
@@ -211,7 +270,7 @@ jobs:
yuezk/gpdev:pkgbuild
- name: Upload pkgbuild artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-pkgbuild
path: |
@@ -226,25 +285,24 @@ jobs:
steps:
- name: Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
path: artifact
pattern: artifact-*
merge-multiple: true
# pattern: artifact-*
# merge-multiple: true
- name: Generate checksum
uses: jmgilman/actions-generate-checksum@v1
with:
output: checksums.txt
patterns: |
artifact/*
# - 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')
prerelease: ${{ contains(github.ref, 'latest') }}
fail_on_unmatched_files: true
files: |
checksums.txt
artifact/*
artifact/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-beta8"
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-beta8"
dependencies = [
"anyhow",
"clap",
@@ -1470,7 +1472,7 @@ dependencies = [
[[package]]
name = "gpclient"
version = "2.0.0-beta.1"
version = "2.0.0-beta8"
dependencies = [
"anyhow",
"clap",
@@ -1491,7 +1493,7 @@ dependencies = [
[[package]]
name = "gpservice"
version = "2.0.0-beta.1"
version = "2.0.0-beta8"
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-beta8"
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-beta8"
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"

245
README.md
View File

@@ -1,194 +1,137 @@
# 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 the FIDO2 authentication (e.g., YubiKey)
- [x] Support authentication using default browser
- [x] Support multiple portals
- [x] Support gateway selection
- [x] Support connect gateway directly
- [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`
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.
## About Trial
## Build & Install from source code
The CLI version is always free, while the GUI version is paid. There two trial modes for the GUI version:
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.
1. 10-day trial: You can use the GUI stable release for 10 days after the installation.
2. 14-day trial: Each beta release has a fresh trial period (at most 14 days) after released.
## [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,35 @@ 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) => bail!("TLS error: certificate verify failed"),
Err(AuthDataError::NotFound) => {
info!("No auth data found, it may not be the /SAML20/SP/ACS endpoint");
@@ -196,10 +225,7 @@ impl<'a> AuthWindow<'a> {
let window = Arc::clone(window);
let cancel_token = CancellationToken::new();
raise_window_cancel_token
.write()
.await
.replace(cancel_token.clone());
raise_window_cancel_token.write().await.replace(cancel_token.clone());
tokio::spawn(async move {
let delay_secs = 1;
@@ -232,7 +258,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,11 +279,10 @@ fn raise_window(window: &Arc<Window>) {
}
}
pub(crate) async fn portal_prelogin(portal: &str, user_agent: &str) -> anyhow::Result<String> {
info!("Portal prelogin...");
match prelogin(portal, user_agent).await? {
pub async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> {
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")),
Prelogin::Standard(_) => bail!("Received non-SAML prelogin response"),
}
}
@@ -392,6 +417,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,
};
@@ -11,38 +13,47 @@ use tempfile::NamedTempFile;
use crate::auth_window::{portal_prelogin, AuthWindow};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
compile_time::date_str!(),
")"
);
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
#[derive(Parser, Clone)]
#[command(version = VERSION)]
struct Cli {
server: String,
#[arg(long)]
gateway: bool,
#[arg(long)]
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 +93,23 @@ 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)
.is_gateway(self.gateway)
.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

@@ -9,12 +9,12 @@ use crate::{
launch_gui::{LaunchGuiArgs, LaunchGuiHandler},
};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
compile_time::date_str!(),
")"
);
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
pub(crate) struct SharedArgs {
pub(crate) fix_openssl: bool,
pub(crate) ignore_tls_errors: bool,
}
#[derive(Subcommand)]
enum CliCommand {
@@ -40,17 +40,18 @@ enum CliCommand {
{usage-heading} {usage}
{all-args}{after-help}
See 'gpclient help <command>' for more information on a specific command.
"
)]
struct Cli {
#[command(subcommand)]
command: CliCommand,
#[arg(
long,
help = "Get around the OpenSSL `unsafe legacy renegotiation` error"
)]
#[arg(long, 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 +68,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 +98,22 @@ 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,66 +2,99 @@ use std::{fs, sync::Arc};
use clap::Args;
use gpapi::{
clap::args::Os,
credential::{Credential, PasswordCredential},
gateway::gateway_login,
gp_params::GpParams,
portal::{prelogin, retrieve_config, Prelogin},
gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, PortalError, Prelogin},
process::auth_launcher::SamlAuthLauncher,
utils::{self, shutdown_signal},
utils::shutdown_signal,
GP_USER_AGENT,
};
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 {
#[arg(help = "The portal server to connect to")]
server: String,
#[arg(
short,
long,
help = "The gateway to connect to, it will prompt if not specified"
)]
#[arg(short, long, help = "The gateway to connect to, it will prompt if not specified")]
gateway: Option<String>,
#[arg(
short,
long,
help = "The username to use, it will prompt if not specified"
)]
#[arg(short, long, help = "The username to use, it will prompt if not specified")]
user: Option<String>,
#[arg(long, short, help = "The VPNC script to use")]
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 server = self.args.server.as_str();
let gp_params = GpParams::builder()
.user_agent(&self.args.user_agent)
.build();
let Err(err) = self.connect_portal_with_prelogin(server).await else {
return Ok(());
};
let prelogin = prelogin(&portal, &self.args.user_agent).await?;
let portal_credential = self.obtain_portal_credential(&prelogin).await?;
let mut portal_config = retrieve_config(&portal, &portal_credential, &gp_params).await?;
info!("Failed to connect portal with prelogin: {}", err);
if err.root_cause().downcast_ref::<PortalError>().is_some() {
info!("Trying the gateway authentication workflow...");
return self.connect_gateway_with_prelogin(server).await;
}
Err(err)
}
async fn connect_portal_with_prelogin(&self, portal: &str) -> anyhow::Result<()> {
let gp_params = self.build_gp_params();
let prelogin = prelogin(portal, &gp_params).await?;
let cred = self.obtain_credential(&prelogin, portal).await?;
let mut portal_config = retrieve_config(portal, &cred, &gp_params).await?;
let selected_gateway = match &self.args.gateway {
Some(gateway) => portal_config
@@ -83,9 +116,32 @@ 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 vpn = Vpn::builder(gateway, &token)
let cookie = match gateway_login(gateway, &cred, &gp_params).await {
Ok(cookie) => cookie,
Err(err) => {
info!("Gateway login failed: {}", err);
return self.connect_gateway_with_prelogin(gateway).await;
}
};
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> {
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, &gateway).await?;
let cookie = gateway_login(gateway, &cred, &gp_params).await?;
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway(&self, gateway: &str, cookie: &str) -> anyhow::Result<()> {
let vpn = Vpn::builder(gateway, cookie)
.user_agent(self.args.user_agent.clone())
.script(self.args.script.clone())
.build();
@@ -110,20 +166,27 @@ impl<'a> ConnectHandler<'a> {
Ok(())
}
async fn obtain_portal_credential(&self, prelogin: &Prelogin) -> anyhow::Result<Credential> {
async fn obtain_credential(&self, prelogin: &Prelogin, server: &str) -> anyhow::Result<Credential> {
let is_gateway = prelogin.is_gateway();
match prelogin {
Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server)
.user_agent(&self.args.user_agent)
.gateway(is_gateway)
.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
}
Prelogin::Standard(prelogin) => {
println!("{}", prelogin.auth_message());
let prefix = if is_gateway { "Gateway" } else { "Portal" };
println!("{} ({}: {})", prelogin.auth_message(), prefix, server);
let user = self.args.user.as_ref().map_or_else(
|| Text::new(&format!("{}:", prelogin.label_username())).prompt(),

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

@@ -6,9 +6,7 @@ use clap::Parser;
use gpapi::{
process::gui_launcher::GuiLauncher,
service::{request::WsRequest, vpn_state::VpnState},
utils::{
crypto::generate_key, env_file, lock_file::LockFile, redact::Redaction, shutdown_signal,
},
utils::{crypto::generate_key, env_file, lock_file::LockFile, redact::Redaction, shutdown_signal},
GP_SERVICE_LOCK_FILE,
};
use log::{info, warn, LevelFilter};
@@ -16,12 +14,7 @@ use tokio::sync::{mpsc, watch};
use crate::{vpn_task::VpnTask, ws_server::WsServer};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
compile_time::date_str!(),
")"
);
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
#[derive(Parser)]
#[command(version = VERSION)]
@@ -51,13 +44,7 @@ impl Cli {
let (vpn_state_tx, vpn_state_rx) = watch::channel(VpnState::Disconnected);
let mut vpn_task = VpnTask::new(ws_req_rx, vpn_state_tx);
let ws_server = WsServer::new(
api_key.clone(),
ws_req_tx,
vpn_state_rx,
lock_file.clone(),
redaction,
);
let ws_server = WsServer::new(api_key.clone(), ws_req_tx, vpn_state_rx, lock_file.clone(), redaction);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(4);
let shutdown_tx_clone = shutdown_tx.clone();
@@ -76,11 +63,7 @@ impl Cli {
if no_gui {
info!("GUI is disabled");
} else {
let envs = self
.env_file
.as_ref()
.map(env_file::load_env_vars)
.transpose()?;
let envs = self.env_file.as_ref().map(env_file::load_env_vars).transpose()?;
let minimized = self.minimized;

View File

@@ -21,10 +21,11 @@ pub(crate) async fn active_gui(State(ctx): State<Arc<WsServerContext>>) -> impl
ctx.send_event(WsEvent::ActiveGui).await;
}
pub(crate) async fn ws_handler(
ws: WebSocketUpgrade,
State(ctx): State<Arc<WsServerContext>>,
) -> impl IntoResponse {
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>>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, ctx))
}

View File

@@ -2,8 +2,8 @@ mod cli;
mod handlers;
mod routes;
mod vpn_task;
mod ws_server;
mod ws_connection;
mod ws_server;
#[tokio::main]
async fn main() {

View File

@@ -1,6 +1,9 @@
use std::sync::Arc;
use axum::{routing::{get, post}, Router};
use axum::{
routing::{get, post},
Router,
};
use crate::{handlers, ws_server::WsServerContext};
@@ -8,6 +11,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

@@ -98,12 +98,7 @@ impl WsServer {
lock_file: Arc<LockFile>,
redaction: Arc<Redaction>,
) -> Self {
let ctx = Arc::new(WsServerContext::new(
api_key,
ws_req_tx,
vpn_state_rx,
redaction,
));
let ctx = Arc::new(WsServerContext::new(api_key, ws_req_tx, vpn_state_rx, redaction));
let cancel_token = CancellationToken::new();
Self {

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)]
@@ -25,11 +27,7 @@ impl SamlAuthResult {
}
impl SamlAuthData {
pub fn new(
username: String,
prelogin_cookie: Option<String>,
portal_userauthcookie: Option<String>,
) -> Self {
pub fn new(username: String, prelogin_cookie: Option<String>, portal_userauthcookie: Option<String>) -> Self {
Self {
username,
prelogin_cookie,
@@ -37,6 +35,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
}
@@ -50,14 +74,17 @@ impl SamlAuthData {
prelogin_cookie: &Option<String>,
portal_userauthcookie: &Option<String>,
) -> bool {
let username_valid = username
.as_ref()
.is_some_and(|username| !username.is_empty());
let username_valid = username.as_ref().is_some_and(|username| !username.is_empty());
let prelogin_cookie_valid = prelogin_cookie.as_ref().is_some_and(|val| val.len() > 5);
let portal_userauthcookie_valid = portal_userauthcookie
.as_ref()
.is_some_and(|val| val.len() > 5);
let portal_userauthcookie_valid = portal_userauthcookie.as_ref().is_some_and(|val| val.len() > 5);
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")]
@@ -112,11 +112,7 @@ pub struct CachedCredential {
}
impl CachedCredential {
pub fn new(
username: String,
password: Option<String>,
auth_cookie: AuthCookieCredential,
) -> Self {
pub fn new(username: String, password: Option<String>, auth_cookie: AuthCookieCredential) -> Self {
Self {
username,
password,
@@ -139,6 +135,24 @@ impl CachedCredential {
pub fn set_auth_cookie(&mut self, auth_cookie: AuthCookieCredential) {
self.auth_cookie = auth_cookie;
}
pub fn set_username(&mut self, username: String) {
self.username = username;
}
pub fn set_password(&mut self, password: Option<String>) {
self.password = password.map(|s| s.to_string());
}
}
impl From<PasswordCredential> for CachedCredential {
fn from(value: PasswordCredential) -> Self {
Self::new(
value.username().to_owned(),
Some(value.password().to_owned()),
AuthCookieCredential::new("", "", ""),
)
}
}
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
@@ -151,6 +165,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 +189,30 @@ 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());
params.insert(
"portal-prelogonuserauthcookie",
cred.prelogon_user_auth_cookie(),
);
}
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(),
);
}
}
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",
portal_prelogonuserauthcookie.unwrap_or_default(),
);
params
}

View File

@@ -1,17 +1,22 @@
use anyhow::bail;
use log::info;
use reqwest::Client;
use roxmltree::Document;
use urlencoding::encode;
use crate::{credential::Credential, gp_params::GpParams};
use crate::{
credential::Credential,
gp_params::GpParams,
utils::{normalize_server, remove_url_scheme},
};
pub async fn gateway_login(
gateway: &str,
cred: &Credential,
gp_params: &GpParams,
) -> anyhow::Result<String> {
let login_url = format!("https://{}/ssl-vpn/login.esp", gateway);
pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParams) -> anyhow::Result<String> {
let url = normalize_server(gateway)?;
let gateway = remove_url_scheme(&url);
let login_url = format!("{}/ssl-vpn/login.esp", url);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
@@ -19,19 +24,18 @@ pub async fn gateway_login(
let extra_params = gp_params.to_params();
params.extend(extra_params);
params.insert("server", gateway);
params.insert("server", &gateway);
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 status = res.status();
if status.is_client_error() || status.is_server_error() {
bail!("Gateway login error: {}", status)
}
let res_xml = res.text().await?;
let doc = Document::parse(&res_xml)?;
build_gateway_token(&doc, gp_params.computer())
@@ -62,11 +66,7 @@ fn build_gateway_token(doc: &Document, computer: &str) -> anyhow::Result<String>
Ok(token)
}
fn read_args<'a>(
args: &'a [String],
index: usize,
key: &'a str,
) -> anyhow::Result<(&'a str, &'a str)> {
fn read_args<'a>(args: &'a [String], index: usize, key: &'a str) -> anyhow::Result<(&'a str, &'a str)> {
args
.get(index)
.ok_or_else(|| anyhow::anyhow!("Failed to read {key} from args"))

View File

@@ -31,6 +31,15 @@ impl Display for Gateway {
}
impl Gateway {
pub fn new(name: String, address: String) -> Self {
Self {
name,
address,
priority: 0,
priority_rules: vec![],
}
}
pub fn name(&self) -> &str {
&self.name
}

View File

@@ -4,9 +4,7 @@ use super::{Gateway, PriorityRule};
pub(crate) fn parse_gateways(doc: &Document) -> Option<Vec<Gateway>> {
let node_gateways = doc.descendants().find(|n| n.has_tag_name("gateways"))?;
let list_gateway = node_gateways
.descendants()
.find(|n| n.has_tag_name("list"))?;
let list_gateway = node_gateways.descendants().find(|n| n.has_tag_name("list"))?;
let gateways = list_gateway
.children()

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

@@ -1,16 +1,16 @@
use anyhow::ensure;
use anyhow::bail;
use log::info;
use reqwest::Client;
use reqwest::{Client, StatusCode};
use roxmltree::Document;
use serde::Serialize;
use specta::Type;
use thiserror::Error;
use crate::{
credential::{AuthCookieCredential, Credential},
gateway::{parse_gateways, Gateway},
gp_params::GpParams,
utils::{normalize_server, xml},
portal::PortalError,
utils::{normalize_server, remove_url_scheme, xml},
};
#[derive(Debug, Serialize, Type)]
@@ -18,25 +18,12 @@ use crate::{
pub struct PortalConfig {
portal: String,
auth_cookie: AuthCookieCredential,
config_cred: Credential,
gateways: Vec<Gateway>,
config_digest: Option<String>,
}
impl PortalConfig {
pub fn new(
portal: String,
auth_cookie: AuthCookieCredential,
gateways: Vec<Gateway>,
config_digest: Option<String>,
) -> Self {
Self {
portal,
auth_cookie,
gateways,
config_digest,
}
}
pub fn portal(&self) -> &str {
&self.portal
}
@@ -49,6 +36,10 @@ impl PortalConfig {
&self.auth_cookie
}
pub fn config_cred(&self) -> &Credential {
&self.config_cred
}
/// In-place sort the gateways by region
pub fn sort_gateways(&mut self, region: &str) {
let preferred_gateway = self.find_preferred_gateway(region);
@@ -88,38 +79,17 @@ impl PortalConfig {
}
// If no gateway is found, return the gateway with the lowest priority
preferred_gateway.unwrap_or_else(|| {
self
.gateways
.iter()
.min_by_key(|gateway| gateway.priority)
.unwrap()
})
preferred_gateway.unwrap_or_else(|| self.gateways.iter().min_by_key(|gateway| gateway.priority).unwrap())
}
}
#[derive(Error, Debug)]
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(
portal: &str,
cred: &Credential,
gp_params: &GpParams,
) -> anyhow::Result<PortalConfig> {
pub async fn retrieve_config(portal: &str, cred: &Credential, gp_params: &GpParams) -> anyhow::Result<PortalConfig> {
let portal = normalize_server(portal)?;
let server = remove_url_scheme(&portal);
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,49 +102,43 @@ 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 status = res.status();
ensure!(!res_xml.is_empty(), PortalConfigError::EmptyResponse);
if status == StatusCode::NOT_FOUND {
bail!(PortalError::ConfigError("Config endpoint not found".to_string()))
}
let doc = Document::parse(&res_xml)?;
let gateways = parse_gateways(&doc).ok_or_else(|| anyhow::anyhow!("Failed to parse gateways"))?;
if status.is_client_error() || status.is_server_error() {
bail!("Portal config error: {}", status)
}
let res_xml = res.text().await.map_err(|e| PortalError::ConfigError(e.to_string()))?;
if res_xml.is_empty() {
bail!(PortalError::ConfigError("Empty portal config response".to_string()))
}
let doc = Document::parse(&res_xml).map_err(|e| PortalError::ConfigError(e.to_string()))?;
let mut gateways = parse_gateways(&doc).unwrap_or_else(|| {
info!("No gateways found in portal config");
vec![]
});
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 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
);
if gateways.is_empty() {
gateways.push(Gateway::new(server.to_string(), server.to_string()));
}
ensure!(
user_auth_cookie != "empty" && prelogon_user_auth_cookie != "empty",
PortalConfigError::InvalidAuthCookie
);
ensure!(!gateways.is_empty(), PortalConfigError::EmptyGateways);
Ok(PortalConfig::new(
server.to_string(),
AuthCookieCredential::new(
cred.username(),
&user_auth_cookie,
&prelogon_user_auth_cookie,
),
Ok(PortalConfig {
portal: server.to_string(),
auth_cookie: AuthCookieCredential::new(cred.username(), &user_auth_cookie, &prelogon_user_auth_cookie),
config_cred: cred.clone(),
gateways,
config_digest,
))
}
fn remove_url_scheme(s: &str) -> String {
s.replace("http://", "").replace("https://", "")
})
}

View File

@@ -3,3 +3,13 @@ mod prelogin;
pub use config::*;
pub use prelogin::*;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PortalError {
#[error("Portal prelogin error: {0}")]
PreloginError(String),
#[error("Portal config error: {0}")]
ConfigError(String),
}

View File

@@ -1,17 +1,34 @@
use anyhow::bail;
use log::{info, trace};
use reqwest::Client;
use log::info;
use reqwest::{Client, StatusCode};
use roxmltree::Document;
use serde::Serialize;
use specta::Type;
use crate::utils::{base64, normalize_server, xml};
use crate::{
gp_params::GpParams,
portal::PortalError,
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,
is_gateway: bool,
saml_request: String,
support_default_browser: bool,
}
impl SamlPrelogin {
@@ -22,12 +39,17 @@ 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)]
#[serde(rename_all = "camelCase")]
pub struct StandardPrelogin {
region: String,
is_gateway: bool,
auth_message: String,
label_username: String,
label_password: String,
@@ -65,24 +87,59 @@ impl Prelogin {
Prelogin::Standard(standard) => standard.region(),
}
}
pub fn is_gateway(&self) -> bool {
match self {
Prelogin::Saml(saml) => saml.is_gateway,
Prelogin::Standard(standard) => standard.is_gateway,
}
}
}
pub async fn prelogin(portal: &str, user_agent: &str) -> anyhow::Result<Prelogin> {
info!("Portal prelogin, user_agent: {}", user_agent);
pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> {
let user_agent = gp_params.user_agent();
info!("Prelogin with 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 is_gateway = gp_params.is_gateway();
let path = if is_gateway { "ssl-vpn" } else { "global-protect" };
let prelogin_url = format!("{portal}/{}/prelogin.esp", path);
let mut params = gp_params.to_params();
let res_xml = client
.get(&prelogin_url)
.send()
.await?
.error_for_status()?
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 status = res.status();
if status == StatusCode::NOT_FOUND {
bail!(PortalError::PreloginError("Prelogin endpoint not found".to_string()))
}
if status.is_client_error() || status.is_server_error() {
bail!("Prelogin error: {}", status)
}
let res_xml = res
.text()
.await?;
.await
.map_err(|e| PortalError::PreloginError(e.to_string()))?;
trace!("Prelogin response: {}", res_xml);
let prelogin = parse_res_xml(res_xml, is_gateway).map_err(|e| PortalError::PreloginError(e.to_string()))?;
Ok(prelogin)
}
fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(&res_xml)?;
let status = xml::get_child_text(&doc, "status")
@@ -98,12 +155,17 @@ 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,
is_gateway,
saml_request,
support_default_browser,
};
return Ok(Prelogin::Saml(saml_prelogin));
@@ -113,10 +175,11 @@ pub async fn prelogin(portal: &str, user_agent: &str) -> anyhow::Result<Prelogin
let label_password = xml::get_child_text(&doc, "password-label");
// Check if the prelogin response is standard login
if label_username.is_some() && label_password.is_some() {
let auth_message = xml::get_child_text(&doc, "authentication-message")
.unwrap_or(String::from("Please enter the login credentials"));
let auth_message =
xml::get_child_text(&doc, "authentication-message").unwrap_or(String::from("Please enter the login credentials"));
let standard_prelogin = StandardPrelogin {
region,
is_gateway,
auth_message,
label_username: label_username.unwrap(),
label_password: label_password.unwrap(),

View File

@@ -8,10 +8,14 @@ use super::command_traits::CommandExt;
pub struct SamlAuthLauncher<'a> {
server: &'a str,
user_agent: Option<&'a str>,
gateway: bool,
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 +23,40 @@ impl<'a> SamlAuthLauncher<'a> {
pub fn new(server: &'a str) -> Self {
Self {
server,
user_agent: None,
gateway: false,
saml_request: None,
user_agent: None,
os: None,
os_version: None,
hidpi: false,
fix_openssl: false,
ignore_tls_errors: false,
clean: false,
}
}
pub fn gateway(mut self, gateway: bool) -> Self {
self.gateway = gateway;
self
}
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 +70,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,22 +85,38 @@ impl<'a> SamlAuthLauncher<'a> {
let mut auth_cmd = Command::new(GP_AUTH_BINARY);
auth_cmd.arg(self.server);
if let Some(user_agent) = self.user_agent {
auth_cmd.arg("--user-agent").arg(user_agent);
if self.gateway {
auth_cmd.arg("--gateway");
}
if let Some(saml_request) = self.saml_request {
auth_cmd.arg("--saml-request").arg(saml_request);
}
if self.fix_openssl {
auth_cmd.arg("--fix-openssl");
if let Some(user_agent) = self.user_agent {
auth_cmd.arg("--user-agent").arg(user_agent);
}
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.ignore_tls_errors {
auth_cmd.arg("--ignore-tls-errors");
}
if self.clean {
auth_cmd.arg("--clean");
}
@@ -85,7 +129,7 @@ impl<'a> SamlAuthLauncher<'a> {
.wait_with_output()
.await?;
let auth_result: SamlAuthResult = serde_json::from_slice(&output.stdout)
let auth_result = serde_json::from_slice::<SamlAuthResult>(&output.stdout)
.map_err(|_| anyhow::anyhow!("Failed to parse auth data"))?;
match auth_result {

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;
@@ -21,8 +21,7 @@ impl CommandExt for Command {
}
fn into_non_root(mut self) -> anyhow::Result<Command> {
let user =
get_non_root_user().map_err(|_| anyhow::anyhow!("{:?} cannot be run as root", self))?;
let user = get_non_root_user().map_err(|_| anyhow::anyhow!("{:?} cannot be run as root", self))?;
self
.env("HOME", user.home_dir())
@@ -42,8 +41,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)
.ok_or_else(|| anyhow::anyhow!("User ({}) not found", current_user))?
uzers::get_user_by_name(&current_user).ok_or_else(|| anyhow::anyhow!("User ({}) not found", current_user))?
};
if user.uid() == 0 {
@@ -60,5 +58,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

@@ -66,10 +66,7 @@ impl GuiLauncher {
let mut non_root_cmd = cmd.into_non_root()?;
let mut child = non_root_cmd
.kill_on_drop(true)
.stdin(Stdio::piped())
.spawn()?;
let mut child = non_root_cmd.kill_on_drop(true).stdin(Stdio::piped()).spawn()?;
let mut stdin = child
.stdin

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

@@ -58,10 +58,7 @@ impl ConnectArgs {
}
pub fn openconnect_os(&self) -> Option<String> {
self
.os
.as_ref()
.map(|os| os.to_openconnect_os().to_string())
self.os.as_ref().map(|os| os.to_openconnect_os().to_string())
}
}

View File

@@ -30,11 +30,13 @@ pub fn normalize_server(server: &str) -> anyhow::Result<String> {
.host_str()
.ok_or(anyhow::anyhow!("Invalid server URL: missing host"))?;
let port: String = normalized_url
.port()
.map_or("".into(), |port| format!(":{}", port));
let port: String = normalized_url.port().map_or("".into(), |port| format!(":{}", port));
let normalized_url = format!("{}://{}{}", scheme, host, port);
Ok(normalized_url)
}
pub fn remove_url_scheme(s: &str) -> String {
s.replace("http://", "").replace("https://", "")
}

View File

@@ -115,12 +115,7 @@ pub fn redact_uri(uri: &str) -> String {
.map(|query| format!("?{}", query))
.unwrap_or_default();
return format!(
"{}://[**********]{}{}",
url.scheme(),
url.path(),
redacted_query
);
return format!("{}://[**********]{}{}", url.scheme(), url.path(), redacted_query);
}
let redacted_query = redact_query(url.query());
@@ -165,10 +160,7 @@ mod tests {
redaction.add_value("foo").unwrap();
assert_eq!(
redaction.redact_str("hello, foo, bar"),
"hello, [**********], bar"
);
assert_eq!(redaction.redact_str("hello, foo, bar"), "hello, [**********], bar");
}
#[test]

View File

@@ -2,9 +2,7 @@ use tokio::signal;
pub async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]

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);
}

View File

@@ -5,8 +5,5 @@ fn main() {
println!("cargo:rerun-if-changed=src/ffi/vpn.h");
// Compile the vpn.c file
cc::Build::new()
.file("src/ffi/vpn.c")
.include("src/ffi")
.compile("vpn");
cc::Build::new().file("src/ffi/vpn.c").include("src/ffi").compile("vpn");
}

View File

@@ -20,10 +20,7 @@ pub(crate) struct ConnectOptions {
#[link(name = "vpn")]
extern "C" {
#[link_name = "vpn_connect"]
fn vpn_connect(
options: *const ConnectOptions,
callback: extern "C" fn(i32, *mut c_void),
) -> c_int;
fn vpn_connect(options: *const ConnectOptions, callback: extern "C" fn(i32, *mut c_void)) -> c_int;
#[link_name = "vpn_disconnect"]
fn vpn_disconnect();

View File

@@ -27,11 +27,7 @@ impl Vpn {
}
pub fn connect(&self, on_connected: impl FnOnce() + 'static + Send + Sync) -> i32 {
self
.callback
.write()
.unwrap()
.replace(Box::new(on_connected));
self.callback.write().unwrap().replace(Box::new(on_connected));
let options = self.build_connect_options();
ffi::connect(&options)
@@ -107,10 +103,7 @@ impl VpnBuilder {
pub fn build(self) -> Vpn {
let user_agent = self.user_agent.unwrap_or_default();
let script = self
.script
.or_else(find_default_vpnc_script)
.unwrap_or_default();
let script = self.script.or_else(find_default_vpnc_script).unwrap_or_default();
let os = self.os.unwrap_or("linux".to_string());
Vpn {

View File

@@ -1,4 +1,4 @@
max_width = 100
max_width = 120
hard_tabs = false
tab_spaces = 2
newline_style = "Unix"