Compare commits

..

71 Commits

Author SHA1 Message Date
Kevin Yue
54d4f2ec57 fix: Cleanup temporary file after feeding auth data
Related: #366
2024-06-11 22:20:49 +08:00
Kevin Yue
a25b5cb894 Release 2.3.1 2024-05-21 20:28:04 +08:00
Kevin Yue
6caa8fcd84 fix: sslkey not working (related #363) 2024-05-21 20:26:37 +08:00
Kevin Yue
66270eee77 chore: update CI 2024-05-20 22:12:03 +08:00
Kevin Yue
6119976027 Release 2.3.0 2024-05-20 21:31:26 +08:00
Kevin Yue
a286b5e418 feat: improve client certificate authentication 2024-05-20 09:08:47 -04:00
Kevin Yue
882ab4001d chore: improve error message 2024-05-19 22:30:40 +08:00
Kevin Yue
52b6fa6fbd feat: support client certificate authentication (related #363) 2024-05-19 18:44:07 +08:00
Kevin Yue
3bb115bd2d Merge branch 'main' into dev 2024-05-19 10:23:00 +08:00
Kevin Yue
e08f239176 fix: do not panic when failed to start service (fix #362) 2024-05-19 10:21:18 +08:00
Kevin Yue
a01c55e38d fix: do not panic when failed to start service (fix #362) 2024-05-19 10:19:21 +08:00
Kevin Yue
af51bc257b feat: add the --reconnect-timeout option 2024-05-19 09:59:25 +08:00
Kevin Yue
90a8c11acb feat: add disable_ipv6 option (related #364) 2024-05-19 09:04:45 +08:00
Kevin Yue
92b858884c fix: check executable for file 2024-05-10 10:26:45 -04:00
Kevin Yue
159673652c Refactor prelogin.rs to use default labels for username and password 2024-05-09 01:48:02 -04:00
Kevin Yue
200d13ef15 Release 2.2.1 2024-05-07 11:58:15 -04:00
Kevin Yue
ddeef46d2e Restore the browser auth, related #360 2024-05-07 11:40:44 -04:00
Dr. Larry D. Pyeatt
97c3998383 Install instructions for Gentoo (#352)
* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

Add install instructions for Gentoo
2024-05-06 19:00:17 +08:00
Kevin Yue
93aea4ee60 doc: using the default browser for CLI 2024-04-30 18:47:38 +08:00
Kevin Yue
546dbf542e Update README.md 2024-04-30 13:28:20 +08:00
Kevin Yue
005410d40b Update README.md 2024-04-30 13:19:52 +08:00
Kevin Yue
3b384a199a Update changelog 2024-04-29 21:56:50 -04:00
Kevin Yue
b62b024a8b Release 2.2.0 2024-04-29 21:05:36 -04:00
Kevin Yue
4fbd373e29 chore: update logging 2024-04-17 21:25:25 +08:00
Kevin Yue
ae211a923a refactor: refine the logging 2024-04-15 22:31:50 +08:00
Kevin Yue
d94d730a44 feat: support default browser for CLI (#345) 2024-04-15 20:27:33 +08:00
Kevin Yue
18ae1c5fa5 refactor: improve gp response parsing 2024-04-14 17:22:37 +08:00
Kevin Yue
a0afabeb04 Release 2.1.4 2024-04-10 10:13:37 -04:00
Kevin Yue
1158ab9095 Add MFA support 2024-04-10 10:07:37 -04:00
Kevin Yue
54ccb761e5 Fix CI 2024-04-07 09:42:00 -04:00
Kevin Yue
f72dbd1dec Release 2.1.3 2024-04-07 20:46:23 +08:00
Kevin Yue
0814c3153a Merge branch 'feature/as_gateway' into release/2.1.3 2024-04-07 20:44:29 +08:00
Kevin Yue
9f085e8b8c Improve code style 2024-04-07 20:31:05 +08:00
Kevin Yue
0188752c0a Bump version 2.1.3 2024-04-06 20:07:57 +08:00
Kevin Yue
a884c41813 Rename PreloginCredential 2024-04-06 19:40:08 +08:00
Kevin Yue
879b977321 Add message for the '--as-gateway' option 2024-04-06 19:26:42 +08:00
Kevin Yue
e9cb253be1 Update dependencies 2024-04-06 19:14:31 +08:00
Kevin Yue
07eacae385 Add '--as-gateway' option (#318) 2024-04-06 19:07:09 +08:00
Kevin Yue
8446874290 Decode extracted gpcallback 2024-04-05 18:01:09 +08:00
Kevin Yue
c347f97b95 Update vite 2024-04-04 18:34:58 +08:00
Kevin Yue
29cfa9e24b Polish authentication 2024-04-04 18:31:48 +08:00
Kevin Yue
1b1ce882a5 Update CI 2024-04-03 21:17:24 +08:00
Kevin Yue
e9f2dbf9ea Support CAS authentication 2024-04-03 06:40:40 -04:00
Kevin Yue
7c6ae315e1 Fix CI 2024-04-02 21:46:30 +08:00
Kevin Yue
cec0d22dc8 Support CAS authentication 2024-04-02 20:06:00 +08:00
Kevin Yue
b2ca82e105 Update changelog 2024-03-29 07:55:10 -04:00
Kevin Yue
5ba6b1d5fc Merge branch 'hotfix/handle_network_error' into release/2.1.2 2024-03-29 07:52:17 -04:00
Kevin Yue
a96e77c758 Bump version 2.1.2 2024-03-29 07:48:02 -04:00
Kevin Yue
79e0f0c7c1 Handle portal endpoint network error 2024-03-29 01:57:53 -04:00
Kevin Yue
187ca778f2 Release 2.1.1 2024-03-25 21:42:16 +08:00
Kevin Yue
2d1aa3ba8c Handle the gateway endpoint error
Related: #338
2024-03-25 21:03:54 +08:00
Kevin Yue
08bd4efefa Improve the error message
Related #327
2024-03-23 20:05:54 +08:00
Kevin Yue
558485f5a9 Add the --hip option 2024-03-17 18:41:42 +08:00
Kevin Yue
cff2ff9dbe Update dependencies 2024-03-16 21:24:41 +08:00
Kevin Yue
d5d92cfbee Ensure vpnc_script and csd_wrapper executable 2024-03-16 21:06:49 +08:00
Kevin Yue
a00f6a8cba Add vpnc_script location, fix #336 2024-03-16 12:05:09 +08:00
Kevin Yue
59dee3d767 Update packaging script 2024-03-11 07:55:49 -04:00
Kevin Yue
e94661b213 Fix build-depends 2024-03-10 08:32:35 -04:00
Kevin Yue
9dea81bdff Update CI 2024-03-10 16:31:18 +08:00
Kevin Yue
6ff552c1ec Update packaging 2024-03-05 08:12:26 -05:00
Kevin Yue
c1b1ea1a67 Update install instructions 2024-02-27 21:05:52 +08:00
Kevin Yue
167a8f4037 Release 2.1.0 2024-02-26 23:45:37 +08:00
Kevin Yue
47776d54d9 Improve packaging (#328)
* Add gpgui-helper (#326)

* Add packaging
2024-02-26 23:33:39 +08:00
Kevin Yue
5767c252b7 Update issue templates 2024-02-17 20:39:11 +08:00
Kevin Yue
a2efcada02 Update README.md 2024-02-13 04:07:18 -05:00
Kevin Yue
e68aa0ffa6 Update README.md 2024-02-13 03:24:20 -05:00
Kevin Yue
66bcccabe4 Add mtu option 2024-02-10 18:19:37 +08:00
Kevin Yue
3736189308 Retry auth if failed to obtain the auth cookie 2024-02-07 19:33:58 +08:00
Kevin Yue
c408482c55 Update install instruction 2024-02-06 20:30:57 +08:00
Kevin Yue
00b0b8eb84 Update README.md 2024-02-06 12:44:18 +08:00
Wesley vieira
b14294f131 update readme with the prerequisites (#313) 2024-02-06 12:43:26 +08:00
109 changed files with 6994 additions and 686 deletions

View File

@@ -7,3 +7,6 @@ indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[{Makefile,Makefile.in}]
indent_style = tab

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
- For the GUI version, you can find the logs at `~/.local/share/gpclient/gpclient.log`
- For the CLI version, copy the output of the `gpclient` command.
**Environment:**
- OS: [e.g. Ubuntu 22.04]
- Desktop Environment: [e.g. GNOME or KDE]
- Output of `ps aux | grep 'gnome-keyring\|kwalletd5' | grep -v grep`: [Required for secure store error]
- Is remote SSH? [Yes/No]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,4 +1,4 @@
name: Build GPGUI
name: Build
on:
push:
paths-ignore:
@@ -8,8 +8,11 @@ on:
- .devcontainer
branches:
- main
- dev
- hotfix/*
- feature/*
- release/*
tags:
- latest
- v*.*.*
jobs:
# Include arm64 if ref is a tag
@@ -22,287 +25,165 @@ jobs:
id: set-matrix
run: |
if [[ "${{ github.ref }}" == "refs/tags/"* ]]; then
echo "matrix=[\"amd64\", \"arm64\"]" >> $GITHUB_OUTPUT
echo 'matrix=[{"runner": "ubuntu-latest", "arch": "amd64"}, {"runner": "arm64", "arch": "arm64"}]' >> $GITHUB_OUTPUT
else
echo "matrix=[\"amd64\"]" >> $GITHUB_OUTPUT
echo 'matrix=[{"runner": "ubuntu-latest", "arch": "amd64"}]' >> $GITHUB_OUTPUT
fi
build-fe:
tarball:
runs-on: ubuntu-latest
needs: [setup-matrix]
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Prepare workspace
run: rm -rf source && mkdir source
- name: Checkout GlobalProtect-openconnect
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
ref: ${{ github.ref }}
path: source/gp
- name: Create tarball
run: |
cd source/gp
# Generate the SNAPSHOT file for non-tagged commits
if [[ "${{ github.ref }}" != "refs/tags/"* ]]; then
touch SNAPSHOT
fi
make tarball
- name: Upload tarball
uses: actions/upload-artifact@v3
with:
name: artifact-source
if-no-files-found: error
path: |
source/gp/.build/tarball/*.tar.gz
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: |
cd app
pnpm install
- name: Build
run: |
cd app
pnpm run build
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: gpgui-fe
path: app/dist
build-tauri-amd64:
needs: [build-fe]
runs-on: ubuntu-latest
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: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build Tauri in Docker
run: |
docker run \
--rm \
-v $(pwd):/${{ github.workspace }} \
-w ${{ github.workspace }} \
-e CI=true \
yuezk/gpdev:main \
"./gpgui/scripts/build.sh"
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
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, package-tarball]
runs-on: ubuntu-latest
build-gp:
needs:
- setup-matrix
- tarball
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
# Only build gp on amd64, as the arm64 package will be built in release.yaml
os: [{runner: ubuntu-latest, arch: amd64}]
package: [deb, rpm, pkg, binary]
runs-on: ${{ matrix.os.runner }}
name: build-gp (${{ matrix.package }}, ${{ matrix.os.arch }})
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Prepare workspace
run: |
rm -rf build-gp-${{ matrix.package }}
mkdir -p build-gp-${{ matrix.package }}
- name: Download tarball
uses: actions/download-artifact@v3
with:
name: artifact-source
path: build-gp-${{ matrix.package }}
- name: Docker Login
run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build ${{ matrix.package }} package in Docker
run: |
docker run --rm \
-v $(pwd)/build-gp-${{ matrix.package }}:/${{ matrix.package }} \
yuezk/gpdev:${{ matrix.package }}-builder
- name: Install ${{ matrix.package }} package in Docker
run: |
docker run --rm \
-e GPGUI_INSTALLED=0 \
-v $(pwd)/build-gp-${{ matrix.package }}:/${{ matrix.package }} \
yuezk/gpdev:${{ matrix.package }}-builder \
bash install.sh
- name: Upload ${{ matrix.package }} package
uses: actions/upload-artifact@v3
with:
name: artifact-gp-${{ matrix.package }}-${{ matrix.os.arch }}
if-no-files-found: error
path: |
build-gp-${{ matrix.package }}/artifacts/*
- name: Download package tarball
uses: actions/download-artifact@v3
with:
name: artifact-tarball
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@v3
with:
name: artifact-${{ matrix.arch }}-rpm
path: |
gpgui/.tmp/artifact/*.rpm
package-pkgbuild:
needs: [setup-matrix, build-tauri-amd64, build-tauri-arm64]
runs-on: ubuntu-latest
build-gpgui:
needs:
- setup-matrix
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
os: ${{fromJson(needs.setup-matrix.outputs.matrix)}}
runs-on: ${{ matrix.os.runner }}
name: build-gpgui (${{ matrix.os.arch }})
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v3
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: |
export CI_ARCH=${{ matrix.arch }}
./gpgui/scripts/generate-pkgbuild.sh
- name: Build PKGBUILD package
run: |
# 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@v3
with:
name: artifact-${{ matrix.arch }}-pkgbuild
path: |
gpgui/.tmp/pkgbuild/*.pkg.tar.zst
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Prepare workspace
run: rm -rf gpgui-source && mkdir gpgui-source
- name: Checkout GlobalProtect-openconnect
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
ref: ${{ github.ref }}
path: gpgui-source/gp
- name: Checkout gpgui@${{ github.ref_name }}
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
ref: ${{ github.ref_name }}
path: gpgui-source/gpgui
- name: Tarball
run: |
cd gpgui-source
tar -czf gpgui.tar.gz gpgui gp
- name: Docker Login
run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build gpgui in Docker
run: |
docker run --rm -v $(pwd)/gpgui-source:/gpgui yuezk/gpdev:gpgui-builder
- name: Install gpgui in Docker
run: |
cd gpgui-source
tar -xJf *.bin.tar.xz
docker run --rm -v $(pwd):/gpgui yuezk/gpdev:gpgui-builder \
bash -c "cd /gpgui/gpgui_*/ && ./gpgui --version"
- name: Upload gpgui
uses: actions/upload-artifact@v3
with:
name: artifact-gpgui-${{ matrix.os.arch }}
if-no-files-found: error
path: |
gpgui-source/*.bin.tar.xz
gpgui-source/*.bin.tar.xz.sha256
gh-release:
if: startsWith(github.ref, 'refs/tags/')
if: ${{ github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest
needs:
- package-rpm
- package-pkgbuild
- tarball
- build-gp
- build-gpgui
steps:
- name: Download artifact
uses: actions/download-artifact@v3
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: |
artifact/artifact-*/*
- name: Prepare workspace
run: rm -rf gh-release && mkdir gh-release
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: gh-release
- name: Create GH release
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
RELEASE_TAG: ${{ github.ref == 'refs/heads/dev' && 'snapshot' || github.ref_name }}
REPO: ${{ github.repository }}
NOTES: ${{ github.ref == 'refs/heads/dev' && '**!!! DO NOT USE THIS RELEASE IN PRODUCTION !!!**' || format('Release {0}', github.ref_name) }}
run: |
gh -R "$REPO" release delete $RELEASE_TAG --yes --cleanup-tag || true
gh -R "$REPO" release create $RELEASE_TAG \
--title "$RELEASE_TAG" \
--notes "$NOTES" \
${{ github.ref == 'refs/heads/dev' && '--target dev' || '' }} \
${{ github.ref == 'refs/heads/dev' && '--prerelease' || '' }} \
gh-release/artifact-source/* \
gh-release/artifact-gpgui-*/*

89
.github/workflows/publish.yaml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: Publish Packages
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish'
required: true
revision:
description: 'Package revision'
required: true
default: "1"
ppa:
description: 'Publish to PPA'
type: boolean
required: true
default: true
obs:
description: 'Publish to OBS'
type: boolean
required: true
default: true
aur:
description: 'Publish to AUR'
type: boolean
required: true
default: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check tag exists
uses: mukunku/tag-exists-action@v1.6.0
id: check-tag
with:
tag: ${{ inputs.tag }}
- name: Exit if tag does not exist
run: |
if [[ "${{ steps.check-tag.outputs.exists }}" == "false" ]]; then
echo "Tag ${{ inputs.tag }} does not exist"
exit 1
fi
publish-ppa:
needs: check
if: ${{ inputs.ppa }}
runs-on: ubuntu-latest
steps:
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Prepare workspace
run: rm -rf publish-ppa && mkdir publish-ppa
- name: Download ${{ inputs.tag }} source code
uses: robinraju/release-downloader@v1.9
with:
token: ${{ secrets.GH_PAT }}
tag: ${{ inputs.tag }}
fileName: globalprotect-openconnect-*.tar.gz
tarBall: false
zipBall: false
out-file-path: publish-ppa
- name: Make the offline tarball
run: |
cd publish-ppa
tar -xf globalprotect-openconnect-*.tar.gz
cd globalprotect-openconnect-*/
make tarball OFFLINE=1
# Prepare the debian directory with custom files
mkdir -p .build/debian
sed 's/@RUST@/rust-all(>=1.70)/g' packaging/deb/control.in > .build/debian/control
sed 's/@OFFLINE@/1/g' packaging/deb/rules.in > .build/debian/rules
cp packaging/deb/postrm .build/debian/postrm
- name: Publish to PPA
uses: yuezk/publish-ppa-package@dev
with:
repository: "yuezk/globalprotect-openconnect"
gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }}
gpg_passphrase: ${{ secrets.PPA_GPG_PASSPHRASE }}
tarball: publish-ppa/globalprotect-openconnect-*/.build/tarball/*.tar.gz
debian_dir: publish-ppa/globalprotect-openconnect-*/.build/debian
deb_email: "k3vinyue@gmail.com"
deb_fullname: "Kevin Yue"
extra_ppa: "liushuyu-011/rust-bpo-1.75"
revision: ${{ inputs.revision }}

153
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: Release Packages
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to release'
required: true
arch:
type: choice
description: 'Architecture to build'
required: true
default: all
options:
- all
- x86_64
- arm64
release-deb:
type: boolean
description: 'Build DEB package'
required: true
default: true
release-rpm:
type: boolean
description: 'Build RPM package'
required: true
default: true
release-pkg:
type: boolean
description: 'Build PKG package'
required: true
default: true
release-binary:
type: boolean
description: 'Build binary package'
required: true
default: true
gh-release:
type: boolean
description: 'Update GitHub release'
required: true
default: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check tag exists
uses: mukunku/tag-exists-action@v1.6.0
id: check-tag
with:
tag: ${{ inputs.tag }}
- name: Exit if tag does not exist
run: |
if [[ "${{ steps.check-tag.outputs.exists }}" == "false" ]]; then
echo "Tag ${{ inputs.tag }} does not exist"
exit 1
fi
setup-matrix:
needs:
- check
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.result }}
steps:
- name: Set up matrix
id: set-matrix
uses: actions/github-script@v7
with:
result-encoding: string
script: |
const inputs = ${{ toJson(inputs) }}
const { arch } = inputs
const osMap = {
"all": ["ubuntu-latest", "arm64"],
"x86_64": ["ubuntu-latest"],
"arm64": ["arm64"]
}
const package = Object.entries(inputs)
.filter(([key, value]) => key.startsWith('release-') && value)
.map(([key, value]) => key.replace('release-', ''))
return JSON.stringify({
os: osMap[arch],
package,
})
build:
needs:
- setup-matrix
strategy:
matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- name: Prepare workspace
run: rm -rf build-${{ matrix.package }} && mkdir -p build-${{ matrix.package }}
- name: Download ${{ inputs.tag }} source code
uses: robinraju/release-downloader@v1.9
with:
token: ${{ secrets.GH_PAT }}
tag: ${{ inputs.tag }}
fileName: globalprotect-openconnect-*.tar.gz
tarBall: false
zipBall: false
out-file-path: build-${{ matrix.package }}
- name: Docker Login
run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build ${{ matrix.package }} package in Docker
run: |
docker run --rm \
-v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} \
-e INCLUDE_GUI=1 \
yuezk/gpdev:${{ matrix.package }}-builder
- name: Install ${{ matrix.package }} package in Docker
run: |
docker run --rm \
-v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} \
yuezk/gpdev:${{ matrix.package }}-builder \
bash install.sh
- name: Upload ${{ matrix.package }} package
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.os }}-${{ matrix.package }}
if-no-files-found: error
path: |
build-${{ matrix.package }}/artifacts/*
gh-release:
needs:
- build
runs-on: ubuntu-latest
if: ${{ inputs.gh-release }}
steps:
- name: Prepare workspace
run: rm -rf gh-release && mkdir gh-release
- name: Download artifact
uses: actions/download-artifact@v3
with:
path: gh-release
- name: Update release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GH_PAT }}
prerelease: ${{ contains(github.ref, 'snapshot') }}
fail_on_unmatched_files: true
tag_name: ${{ inputs.tag }}
files: |
gh-release/artifact-*/*

6
.gitignore vendored
View File

@@ -2,3 +2,9 @@
/target
.pnpm-store
.env
.vendor
*.tar.xz
.cargo
.build
SNAPSHOT

View File

@@ -1,6 +1,7 @@
{
"cSpell.words": [
"authcookie",
"badssl",
"bincode",
"chacha",
"clientos",
@@ -25,7 +26,9 @@
"LOGNAME",
"oneshot",
"openconnect",
"pkcs",
"pkexec",
"pkey",
"Prelogin",
"prelogon",
"prelogonuserauthcookie",
@@ -35,6 +38,7 @@
"rspc",
"servercert",
"specta",
"sslkey",
"sysinfo",
"tanstack",
"tauri",

144
Cargo.lock generated
View File

@@ -252,6 +252,12 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -562,6 +568,13 @@ dependencies = [
"memchr",
]
[[package]]
name = "common"
version = "2.3.1"
dependencies = [
"is_executable",
]
[[package]]
name = "compile-time"
version = "0.2.0"
@@ -1423,7 +1436,7 @@ dependencies = [
[[package]]
name = "gpapi"
version = "2.0.0"
version = "2.3.1"
dependencies = [
"anyhow",
"base64 0.21.5",
@@ -1433,6 +1446,8 @@ dependencies = [
"log",
"md5",
"open",
"openssl",
"pem",
"redact-engine",
"regex",
"reqwest",
@@ -1440,6 +1455,7 @@ dependencies = [
"serde",
"serde_json",
"serde_urlencoded",
"sha256",
"specta",
"specta-macros",
"tauri",
@@ -1454,13 +1470,14 @@ dependencies = [
[[package]]
name = "gpauth"
version = "2.0.0"
version = "2.3.1"
dependencies = [
"anyhow",
"clap",
"compile-time",
"env_logger",
"gpapi",
"html-escape",
"log",
"regex",
"serde_json",
@@ -1474,10 +1491,11 @@ dependencies = [
[[package]]
name = "gpclient"
version = "2.0.0"
version = "2.3.1"
dependencies = [
"anyhow",
"clap",
"common",
"compile-time",
"directories",
"env_logger",
@@ -1493,9 +1511,27 @@ dependencies = [
"whoami",
]
[[package]]
name = "gpgui-helper"
version = "2.3.1"
dependencies = [
"anyhow",
"clap",
"compile-time",
"env_logger",
"futures-util",
"gpapi",
"log",
"reqwest",
"tauri",
"tauri-build",
"tempfile",
"tokio",
]
[[package]]
name = "gpservice"
version = "2.0.0"
version = "2.3.1"
dependencies = [
"anyhow",
"axum",
@@ -1506,9 +1542,12 @@ dependencies = [
"gpapi",
"log",
"openconnect",
"serde",
"serde_json",
"tar",
"tokio",
"tokio-util",
"xz2",
]
[[package]]
@@ -1568,9 +1607,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.24"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
dependencies = [
"bytes",
"fnv",
@@ -1587,9 +1626,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943"
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
dependencies = [
"bytes",
"fnv",
@@ -1643,6 +1682,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@@ -1747,7 +1795,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.24",
"h2 0.3.26",
"http 0.2.11",
"http-body 0.4.6",
"httparse",
@@ -1770,7 +1818,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2 0.4.2",
"h2 0.4.4",
"http 1.0.0",
"http-body 1.0.0",
"httparse",
@@ -2178,6 +2226,17 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -2267,9 +2326,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
@@ -2486,10 +2545,10 @@ dependencies = [
[[package]]
name = "openconnect"
version = "2.0.0"
version = "2.3.1"
dependencies = [
"cc",
"is_executable",
"common",
"log",
]
@@ -2619,6 +2678,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]]
name = "pem"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -3116,7 +3185,7 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.3.24",
"h2 0.3.26",
"http 0.2.11",
"http-body 0.4.6",
"hyper 0.14.28",
@@ -3448,6 +3517,19 @@ dependencies = [
"digest",
]
[[package]]
name = "sha256"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0"
dependencies = [
"async-trait",
"bytes",
"hex",
"sha2",
"tokio",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -4101,9 +4183,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.35.1"
version = "1.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
dependencies = [
"backtrace",
"bytes",
@@ -4430,6 +4512,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8parse"
version = "0.2.1"
@@ -4536,6 +4624,12 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.89"
@@ -4712,11 +4806,12 @@ dependencies = [
[[package]]
name = "whoami"
version = "1.4.1"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50"
checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9"
dependencies = [
"wasm-bindgen",
"redox_syscall",
"wasite",
"web-sys",
]
@@ -5091,6 +5186,15 @@ version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "zeroize"
version = "1.7.0"

View File

@@ -1,10 +1,11 @@
[workspace]
resolver = "2"
members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"]
members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth", "apps/gpgui-helper/src-tauri"]
[workspace.package]
version = "2.0.0"
rust-version = "1.70"
version = "2.3.1"
authors = ["Kevin Yue <k3vinyue@gmail.com>"]
homepage = "https://github.com/yuezk/GlobalProtect-openconnect"
edition = "2021"
@@ -21,6 +22,8 @@ is_executable = "1.0"
log = "0.4"
regex = "1"
reqwest = { version = "0.11", features = ["native-tls-vendored", "json"] }
openssl = "0.10"
pem = "3"
roxmltree = "0.18"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -34,17 +37,21 @@ axum = "0.7"
futures = "0.3"
futures-util = "0.3"
tokio-tungstenite = "0.20.1"
specta = "=2.0.0-rc.1"
specta-macros = "=2.0.0-rc.1"
uzers = "0.11"
whoami = "1"
tauri = { version = "1.5" }
thiserror = "1"
redact-engine = "0.1"
dotenvy_macro = "0.15"
compile-time = "0.2"
serde_urlencoded = "0.7"
md5="0.7"
sha256="1"
# Tauri dependencies
tauri = { version = "1.5" }
specta = "=2.0.0-rc.1"
specta-macros = "=2.0.0-rc.1"
rspc = { version = "1.0.0-rc.5", features = ["tauri"] }
[profile.release]
opt-level = 'z' # Optimize for size

263
Makefile Normal file
View File

@@ -0,0 +1,263 @@
.SHELLFLAGS += -e
OFFLINE ?= 0
BUILD_FE ?= 1
INCLUDE_GUI ?= 0
CARGO ?= cargo
VERSION = $(shell $(CARGO) metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
REVISION ?= 1
PPA_REVISION ?= 1
PKG_NAME = globalprotect-openconnect
PKG = $(PKG_NAME)-$(VERSION)
SERIES ?= $(shell lsb_release -cs)
PUBLISH ?= 0
export DEBEMAIL = k3vinyue@gmail.com
export DEBFULLNAME = Kevin Yue
export SNAPSHOT = $(shell test -f SNAPSHOT && echo "true" || echo "false")
ifeq ($(SNAPSHOT), true)
RELEASE_TAG = snapshot
else
RELEASE_TAG = v$(VERSION)
endif
CARGO_BUILD_ARGS = --release
ifeq ($(OFFLINE), 1)
CARGO_BUILD_ARGS += --frozen
endif
default: build
version:
@echo $(VERSION)
clean-tarball:
rm -rf .build/tarball
rm -rf .vendor
rm -rf vendor.tar.xz
rm -rf .cargo
# Create a tarball, include the cargo dependencies if OFFLINE is set to 1
tarball: clean-tarball
if [ $(BUILD_FE) -eq 1 ]; then \
echo "Building frontend..."; \
cd apps/gpgui-helper && pnpm install && pnpm build; \
fi
# Remove node_modules to reduce the tarball size
rm -rf apps/gpgui-helper/node_modules
mkdir -p .cargo
mkdir -p .build/tarball
# If OFFLINE is set to 1, vendor all cargo dependencies
if [ $(OFFLINE) -eq 1 ]; then \
$(CARGO) vendor .vendor > .cargo/config.toml; \
tar -cJf vendor.tar.xz .vendor; \
fi
@echo "Creating tarball..."
tar --exclude .vendor --exclude target --transform 's,^,${PKG}/,' -czf .build/tarball/${PKG}.tar.gz * .cargo
download-gui:
rm -rf .build/gpgui
if [ $(INCLUDE_GUI) -eq 1 ]; then \
echo "Downloading GlobalProtect GUI..."; \
mkdir -p .build/gpgui; \
curl -sSL https://github.com/yuezk/GlobalProtect-openconnect/releases/download/$(RELEASE_TAG)/gpgui_$(shell uname -m).bin.tar.xz \
-o .build/gpgui/gpgui_$(shell uname -m).bin.tar.xz; \
tar -xJf .build/gpgui/*.tar.xz -C .build/gpgui; \
else \
echo "Skipping GlobalProtect GUI download (INCLUDE_GUI=0)"; \
fi
build: download-gui build-fe build-rs
# Install and build the frontend
# If OFFLINE is set to 1, skip it
build-fe:
if [ $(OFFLINE) -eq 1 ] || [ $(BUILD_FE) -eq 0 ]; then \
echo "Skipping frontend build (OFFLINE=1 or BUILD_FE=0)"; \
else \
cd apps/gpgui-helper && pnpm install && pnpm build; \
fi
if [ ! -d apps/gpgui-helper/dist ]; then \
echo "Error: frontend build failed"; \
exit 1; \
fi
build-rs:
if [ $(OFFLINE) -eq 1 ]; then \
tar -xJf vendor.tar.xz; \
fi
$(CARGO) build $(CARGO_BUILD_ARGS) -p gpclient -p gpservice -p gpauth
$(CARGO) build $(CARGO_BUILD_ARGS) -p gpgui-helper --features "tauri/custom-protocol"
clean:
$(CARGO) clean
rm -rf .build
rm -rf .vendor
rm -rf apps/gpgui-helper/node_modules
install:
@echo "Installing $(PKG_NAME)..."
install -Dm755 target/release/gpclient $(DESTDIR)/usr/bin/gpclient
install -Dm755 target/release/gpauth $(DESTDIR)/usr/bin/gpauth
install -Dm755 target/release/gpservice $(DESTDIR)/usr/bin/gpservice
install -Dm755 target/release/gpgui-helper $(DESTDIR)/usr/bin/gpgui-helper
if [ -f .build/gpgui/gpgui_*/gpgui ]; then \
install -Dm755 .build/gpgui/gpgui_*/gpgui $(DESTDIR)/usr/bin/gpgui; \
fi
install -Dm644 packaging/files/usr/share/applications/gpgui.desktop $(DESTDIR)/usr/share/applications/gpgui.desktop
install -Dm644 packaging/files/usr/share/icons/hicolor/scalable/apps/gpgui.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg
install -Dm644 packaging/files/usr/share/icons/hicolor/32x32/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/32x32/apps/gpgui.png
install -Dm644 packaging/files/usr/share/icons/hicolor/128x128/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/128x128/apps/gpgui.png
install -Dm644 packaging/files/usr/share/icons/hicolor/256x256@2/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/256x256@2/apps/gpgui.png
install -Dm644 packaging/files/usr/share/polkit-1/actions/com.yuezk.gpgui.policy $(DESTDIR)/usr/share/polkit-1/actions/com.yuezk.gpgui.policy
uninstall:
@echo "Uninstalling $(PKG_NAME)..."
rm -f $(DESTDIR)/usr/bin/gpclient
rm -f $(DESTDIR)/usr/bin/gpauth
rm -f $(DESTDIR)/usr/bin/gpservice
rm -f $(DESTDIR)/usr/bin/gpgui-helper
rm -f $(DESTDIR)/usr/bin/gpgui
rm -f $(DESTDIR)/usr/share/applications/gpgui.desktop
rm -f $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg
rm -f $(DESTDIR)/usr/share/icons/hicolor/32x32/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/icons/hicolor/128x128/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/icons/hicolor/256x256@2/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/polkit-1/actions/com.yuezk.gpgui.policy
clean-debian:
rm -rf .build/deb
# Generate the debian package structure, without the changelog
init-debian: clean-debian tarball
mkdir -p .build/deb
cp .build/tarball/${PKG}.tar.gz .build/deb
tar -xzf .build/deb/${PKG}.tar.gz -C .build/deb
cd .build/deb/${PKG} && debmake
cp -f packaging/deb/control.in .build/deb/$(PKG)/debian/control
cp -f packaging/deb/rules.in .build/deb/$(PKG)/debian/rules
cp -f packaging/deb/postrm .build/deb/$(PKG)/debian/postrm
sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/deb/$(PKG)/debian/rules
rm -f .build/deb/$(PKG)/debian/changelog
deb: init-debian
# Remove the rust build depdency from the control file
sed -i "s/@RUST@//g" .build/deb/$(PKG)/debian/control
cd .build/deb/$(PKG) && dch --create --distribution unstable --package $(PKG_NAME) --newversion $(VERSION)-$(REVISION) "Bugfix and improvements."
cd .build/deb/$(PKG) && debuild --preserve-env -e PATH -us -uc -b
check-ppa:
if [ $(OFFLINE) -eq 0 ]; then \
echo "Error: ppa build requires offline mode (OFFLINE=1)"; \
fi
# Usage: make ppa SERIES=focal OFFLINE=1 PUBLISH=1
ppa: check-ppa init-debian
sed -i "s/@RUST@/rust-all(>=1.70)/g" .build/deb/$(PKG)/debian/control
$(eval SERIES_VER = $(shell distro-info --series $(SERIES) -r | cut -d' ' -f1))
@echo "Building for $(SERIES) $(SERIES_VER)"
rm -rf .build/deb/$(PKG)/debian/changelog
cd .build/deb/$(PKG) && dch --create --distribution $(SERIES) --package $(PKG_NAME) --newversion $(VERSION)-$(REVISION)ppa$(PPA_REVISION)~ubuntu$(SERIES_VER) "Bugfix and improvements."
cd .build/deb/$(PKG) && echo "y" | debuild -e PATH -S -sa -k"$(GPG_KEY_ID)" -p"gpg --batch --passphrase $(GPG_KEY_PASS) --pinentry-mode loopback"
if [ $(PUBLISH) -eq 1 ]; then \
cd .build/deb/$(PKG) && dput ppa:yuezk/globalprotect-openconnect ../*.changes; \
else \
echo "Skipping ppa publish (PUBLISH=0)"; \
fi
clean-rpm:
rm -rf .build/rpm
# Generate RPM sepc file
init-rpm: clean-rpm
mkdir -p .build/rpm
cp packaging/rpm/globalprotect-openconnect.spec.in .build/rpm/globalprotect-openconnect.spec
cp packaging/rpm/globalprotect-openconnect.changes.in .build/rpm/globalprotect-openconnect.changes
sed -i "s/@VERSION@/$(VERSION)/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@REVISION@/$(REVISION)/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@DATE@/$(shell LC_ALL=en.US date "+%a %b %d %Y")/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@VERSION@/$(VERSION)/g" .build/rpm/globalprotect-openconnect.changes
sed -i "s/@DATE@/$(shell LC_ALL=en.US date -u "+%a %b %e %T %Z %Y")/g" .build/rpm/globalprotect-openconnect.changes
rpm: init-rpm tarball
rm -rf $(HOME)/rpmbuild
rpmdev-setuptree
cp .build/tarball/${PKG}.tar.gz $(HOME)/rpmbuild/SOURCES/${PKG_NAME}.tar.gz
rpmbuild -ba .build/rpm/globalprotect-openconnect.spec
# Copy RPM package from build directory
cp $(HOME)/rpmbuild/RPMS/$(shell uname -m)/$(PKG_NAME)*.rpm .build/rpm
# Copy the SRPM only for x86_64.
if [ "$(shell uname -m)" = "x86_64" ]; then \
cp $(HOME)/rpmbuild/SRPMS/$(PKG_NAME)*.rpm .build/rpm; \
fi
clean-pkgbuild:
rm -rf .build/pkgbuild
init-pkgbuild: clean-pkgbuild tarball
mkdir -p .build/pkgbuild
cp .build/tarball/${PKG}.tar.gz .build/pkgbuild
cp packaging/pkgbuild/PKGBUILD.in .build/pkgbuild/PKGBUILD
sed -i "s/@PKG_NAME@/$(PKG_NAME)/g" .build/pkgbuild/PKGBUILD
sed -i "s/@VERSION@/$(VERSION)/g" .build/pkgbuild/PKGBUILD
sed -i "s/@REVISION@/$(REVISION)/g" .build/pkgbuild/PKGBUILD
sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/pkgbuild/PKGBUILD
pkgbuild: init-pkgbuild
cd .build/pkgbuild && makepkg -s --noconfirm
clean-binary:
rm -rf .build/binary
binary: clean-binary tarball
mkdir -p .build/binary
cp .build/tarball/${PKG}.tar.gz .build/binary
tar -xzf .build/binary/${PKG}.tar.gz -C .build/binary
mkdir -p .build/binary/$(PKG_NAME)_$(VERSION)/artifacts
make -C .build/binary/${PKG} build OFFLINE=$(OFFLINE) BUILD_FE=0 INCLUDE_GUI=$(INCLUDE_GUI)
make -C .build/binary/${PKG} install DESTDIR=$(PWD)/.build/binary/$(PKG_NAME)_$(VERSION)/artifacts
cp packaging/binary/Makefile.in .build/binary/$(PKG_NAME)_$(VERSION)/Makefile
# Create a tarball for the binary package
tar -cJf .build/binary/$(PKG_NAME)_$(VERSION)_$(shell uname -m).bin.tar.xz -C .build/binary $(PKG_NAME)_$(VERSION)
# Generate sha256sum
cd .build/binary && sha256sum $(PKG_NAME)_$(VERSION)_$(shell uname -m).bin.tar.xz | cut -d' ' -f1 > $(PKG_NAME)_$(VERSION)_$(shell uname -m).bin.tar.xz.sha256

114
README.md
View File

@@ -13,6 +13,7 @@ A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authenticati
- [x] Support both SSO and non-SSO authentication
- [x] Support the FIDO2 authentication (e.g., YubiKey)
- [x] Support authentication using default browser
- [x] Support client certificate authentication
- [x] Support multiple portals
- [x] Support gateway selection
- [x] Support connect gateway directly
@@ -43,6 +44,12 @@ Options:
See 'gpclient help <command>' for more information on a specific command.
```
To use the default browser for authentication with the CLI version, you need to use the following command:
```bash
sudo -E gpclient connect --default-browser <portal>
```
### 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.
@@ -53,20 +60,12 @@ The GUI version is also available after you installed it. You can launch it from
## 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
#### Install from PPA (Ubuntu 18.04 and later, except 24.04)
```
sudo apt-get install gir1.2-gtk-3.0 gir1.2-webkit2-4.0
sudo add-apt-repository ppa:yuezk/globalprotect-openconnect
sudo apt-get update
sudo apt-get install globalprotect-openconnect
@@ -76,12 +75,29 @@ sudo apt-get install globalprotect-openconnect
>
> 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
#### **Ubuntu 24.04 and later**
Download the latest deb package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `dpkg`:
The `libwebkit2gtk-4.0-37` package was [removed](https://bugs.launchpad.net/ubuntu/+source/webkit2gtk/+bug/2061914) from its repo, before [the issue](https://github.com/yuezk/GlobalProtect-openconnect/issues/351) gets resolved, you need to install them manually:
```bash
sudo dpkg -i globalprotect-openconnect_*.deb
wget http://launchpadlibrarian.net/704701349/libwebkit2gtk-4.0-37_2.43.3-1_amd64.deb
wget http://launchpadlibrarian.net/704701345/libjavascriptcoregtk-4.0-18_2.43.3-1_amd64.deb
sudo dpkg --install *.deb
```
And the latest package is not available in the PPA, you can follow the [Install from deb package](#install-from-deb-package) section to install the latest package.
#### **Ubuntu 18.04**
The latest package is not available in the PPA either, but you still needs to add the `ppa:yuezk/globalprotect-openconnect` repo beforehand to use the required `openconnect` package. Then you can follow the [Install from deb package](#install-from-deb-package) section to install the latest package.
#### Install from deb package
Download the latest deb package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `apt`:
```bash
sudo apt install --fix-broken globalprotect-openconnect_*.deb
```
### Arch Linux / Manjaro
@@ -102,7 +118,7 @@ Download the latest package from [releases](https://github.com/yuezk/GlobalProte
sudo pacman -U globalprotect-openconnect-*.pkg.tar.zst
```
### Fedora/OpenSUSE/CentOS/RHEL
### Fedora 38 and later / Fedora Rawhide
#### Install from COPR
@@ -113,17 +129,83 @@ sudo dnf copr enable yuezk/globalprotect-openconnect
sudo dnf install globalprotect-openconnect
```
#### Install from OBS
### openSUSE Leap 15.6 / openSUSE Tumbleweed
#### Install from OBS (openSUSE Build Service)
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.
### Other RPM-based distributions
#### Install from RPM package
Download the latest RPM package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page.
```bash
sudo rpm -i globalprotect-openconnect-*.rpm
```
### Gentoo
Install from the ```rios``` or ```slonko``` overlays. Example using rios:
#### 1. Enable the overlay
```
sudo eselect repository enable rios
```
#### 2. Sync with the repository
- If you have eix installed, use it:
```
sudo eix-sync
```
- Otherwise, use:
```
sudo emerge --sync
```
#### 3. Install
```sudo emerge globalprotect-openconnect```
### Other distributions
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.
- Install `openconnect >= 8.20`, `webkit2gtk`, `libsecret`, `libayatana-appindicator` or `libappindicator-gtk3`.
- Download `globalprotect-openconnect_${version}_${arch}.bin.tar.xz` from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page.
- Extract the tarball with `tar -xJf globalprotect-openconnect_${version}_${arch}.bin.tar.xz`.
- Run `sudo make install` to install the client.
## Build from source
You can also build the client from source, steps are as follows:
### Prerequisites
- [Install Rust](https://www.rust-lang.org/tools/install)
- Install Tauri dependencies: https://tauri.app/v1/guides/getting-started/prerequisites/#setting-up-linux
- Install `perl`
- Install `openconnect >= 8.20` and `libopenconnect-dev` (or `openconnect-devel` on RPM-based distributions)
- Install `pkexec`, `gnome-keyring` (or `pam_kwallet` on KDE)
### Build
1. Download the source code tarball from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Choose `globalprotect-openconnect-${version}.tar.gz`.
2. Extract the tarball with `tar -xzf globalprotect-openconnect-${version}.tar.gz`.
3. Enter the source directory and run `make build BUILD_FE=0` to build the client.
3. Run `sudo make install` to install the client. (Note, `DESTDIR` is not supported)
## FAQ
1. How to deal with error `Secure Storage not ready`
Try upgrade the client to `2.2.0` or later, which will use a file-based storage as a fallback.
You need to install the `gnome-keyring` package, and restart the system (See [#321](https://github.com/yuezk/GlobalProtect-openconnect/issues/321), [#316](https://github.com/yuezk/GlobalProtect-openconnect/issues/316)).
2. How to deal with error `(gpauth:18869): Gtk-WARNING **: 10:33:37.566: cannot open display:`
If you encounter this error when using the CLI version, try to run the command with `sudo -E` (See [#316](https://github.com/yuezk/GlobalProtect-openconnect/issues/316)).
## About Trial

View File

@@ -8,7 +8,11 @@ license.workspace = true
tauri-build = { version = "1.5", features = [] }
[dependencies]
gpapi = { path = "../../crates/gpapi", features = ["tauri", "clap"] }
gpapi = { path = "../../crates/gpapi", features = [
"tauri",
"clap",
"browser-auth",
] }
anyhow.workspace = true
clap.workspace = true
env_logger.workspace = true
@@ -18,6 +22,7 @@ serde_json.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tempfile.workspace = true
html-escape = "0.2.13"
webkit2gtk = "0.18.2"
tauri = { workspace = true, features = ["http-all"] }
compile-time.workspace = true

View File

@@ -7,6 +7,7 @@ use std::{
use anyhow::bail;
use gpapi::{
auth::SamlAuthData,
error::AuthDataParseError,
gp_params::GpParams,
portal::{prelogin, Prelogin},
utils::{redact::redact_uri, window::WindowExt},
@@ -184,6 +185,10 @@ impl<'a> AuthWindow<'a> {
}
info!("Loaded uri: {}", redact_uri(&uri));
if uri.starts_with("globalprotectcallback:") {
return;
}
read_auth_data(&main_resource, auth_result_tx_clone.clone());
}
});
@@ -202,7 +207,9 @@ impl<'a> AuthWindow<'a> {
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);
if !uri.starts_with("globalprotectcallback:") {
warn!("Failed to load uri: {} with error: {}", redacted_uri, err);
}
// 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.
@@ -339,7 +346,7 @@ fn read_auth_data_from_headers(response: &URIResponse) -> AuthResult {
fn read_auth_data_from_body<F>(main_resource: &WebResource, callback: F)
where
F: FnOnce(AuthResult) + Send + 'static,
F: FnOnce(Result<SamlAuthData, AuthDataParseError>) + Send + 'static,
{
main_resource.data(Cancellable::NONE, |data| match data {
Ok(data) => {
@@ -348,53 +355,41 @@ where
}
Err(err) => {
info!("Failed to read response body: {}", err);
callback(Err(AuthDataError::Invalid))
callback(Err(AuthDataParseError::Invalid))
}
});
}
fn read_auth_data_from_html(html: &str) -> AuthResult {
fn read_auth_data_from_html(html: &str) -> Result<SamlAuthData, AuthDataParseError> {
if html.contains("Temporarily Unavailable") {
info!("Found 'Temporarily Unavailable' in HTML, auth failed");
return Err(AuthDataError::Invalid);
return Err(AuthDataParseError::Invalid);
}
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");
SamlAuthData::from_html(html).or_else(|err| {
if let Some(gpcallback) = extract_gpcallback(html) {
info!("Found gpcallback from html...");
SamlAuthData::from_gpcallback(&gpcallback)
} else {
Err(err)
}
})
}
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
return Ok(SamlAuthData::new(
username.unwrap(),
prelogin_cookie,
portal_userauthcookie,
));
}
info!("Found invalid auth data in HTML");
Err(AuthDataError::Invalid)
}
Some(status) => {
info!("Found invalid SAML status {} in HTML", status);
Err(AuthDataError::Invalid)
}
None => {
info!("No auth data found in HTML");
Err(AuthDataError::NotFound)
}
}
fn extract_gpcallback(html: &str) -> Option<String> {
let re = Regex::new(r#"globalprotectcallback:[^"]+"#).unwrap();
re.captures(html)
.and_then(|captures| captures.get(0))
.map(|m| html_escape::decode_html_entities(m.as_str()).to_string())
}
fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSender<AuthResult>) {
if main_resource.response().is_none() {
let Some(response) = main_resource.response() else {
info!("No response found in main resource");
send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
return;
}
};
let response = main_resource.response().unwrap();
info!("Trying to read auth data from response headers...");
match read_auth_data_from_headers(&response) {
@@ -407,13 +402,30 @@ fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSe
read_auth_data_from_body(main_resource, move |auth_result| {
// Since we have already found invalid auth data in headers, which means this could be the `/SAML20/SP/ACS` endpoint
// any error result from body should be considered as invalid, and trigger a retry
let auth_result = auth_result.map_err(|_| AuthDataError::Invalid);
let auth_result = auth_result.map_err(|err| {
info!("Failed to read auth data from body: {}", err);
AuthDataError::Invalid
});
send_auth_result(&auth_result_tx, auth_result);
});
}
Err(AuthDataError::NotFound) => {
info!("No auth data found in headers, trying to read from body...");
let is_acs_endpoint = main_resource.uri().map_or(false, |uri| uri.contains("/SAML20/SP/ACS"));
read_auth_data_from_body(main_resource, move |auth_result| {
// If the endpoint is `/SAML20/SP/ACS` and no auth data found in body, it should be considered as invalid
let auth_result = auth_result.map_err(|err| {
info!("Failed to read auth data from body: {}", err);
if !is_acs_endpoint && matches!(err, AuthDataParseError::NotFound) {
AuthDataError::NotFound
} else {
AuthDataError::Invalid
}
});
send_auth_result(&auth_result_tx, auth_result)
});
}
@@ -425,13 +437,6 @@ fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSe
}
}
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())
}
pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel::<Result<(), String>>();
@@ -477,3 +482,42 @@ pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()>
rx.await?.map_err(|err| anyhow::anyhow!(err))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_gpcallback_some() {
let html = r#"
<meta http-equiv="refresh" content="0; URL=globalprotectcallback:PGh0bWw+PCEtLSA8c">
<meta http-equiv="refresh" content="0; URL=globalprotectcallback:PGh0bWw+PCEtLSA8c">
"#;
assert_eq!(
extract_gpcallback(html).as_deref(),
Some("globalprotectcallback:PGh0bWw+PCEtLSA8c")
);
}
#[test]
fn extract_gpcallback_cas() {
let html = r#"
<meta http-equiv="refresh" content="0; URL=globalprotectcallback:cas-as=1&amp;un=xyz@email.com&amp;token=very_long_string">
"#;
assert_eq!(
extract_gpcallback(html).as_deref(),
Some("globalprotectcallback:cas-as=1&un=xyz@email.com&token=very_long_string")
);
}
#[test]
fn extract_gpcallback_none() {
let html = r#"
<meta http-equiv="refresh" content="0; URL=PGh0bWw+PCEtLSA8c">
"#;
assert_eq!(extract_gpcallback(html), None);
}
}

View File

@@ -3,6 +3,7 @@ use gpapi::{
auth::{SamlAuthData, SamlAuthResult},
clap::args::Os,
gp_params::{ClientOs, GpParams},
process::browser_authenticator::BrowserAuthenticator,
utils::{normalize_server, openssl},
GP_USER_AGENT,
};
@@ -37,6 +38,8 @@ struct Cli {
ignore_tls_errors: bool,
#[arg(long)]
clean: bool,
#[arg(long)]
default_browser: bool,
}
impl Cli {
@@ -56,6 +59,15 @@ impl Cli {
None => portal_prelogin(&self.server, &gp_params).await?,
};
if self.default_browser {
let browser_auth = BrowserAuthenticator::new(&saml_request);
browser_auth.authenticate()?;
info!("Please continue the authentication process in the default browser");
return Ok(());
}
self.saml_request.replace(saml_request);
let app = create_app(self.clone())?;

View File

@@ -22,8 +22,8 @@
"all": true,
"request": true,
"scope": [
"http://**",
"https://**"
"http://*",
"https://*"
]
}
},

View File

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

View File

@@ -1,24 +1,27 @@
use std::{fs, sync::Arc};
use std::{cell::RefCell, fs, sync::Arc};
use clap::Args;
use common::vpn_utils::find_csd_wrapper;
use gpapi::{
clap::args::Os,
credential::{Credential, PasswordCredential},
gateway::gateway_login,
error::PortalError,
gateway::{gateway_login, GatewayLogin},
gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, PortalError, Prelogin},
portal::{prelogin, retrieve_config, Prelogin},
process::{
auth_launcher::SamlAuthLauncher,
users::{get_non_root_user, get_user_by_name},
},
utils::shutdown_signal,
utils::{request::RequestIdentityError, shutdown_signal},
GP_USER_AGENT,
};
use inquire::{Password, PasswordDisplayMode, Select, Text};
use log::info;
use openconnect::Vpn;
use tokio::{io::AsyncReadExt, net::TcpListener};
use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE};
use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE, GP_CLIENT_PORT_FILE};
#[derive(Args)]
pub(crate) struct ConnectArgs {
@@ -30,6 +33,25 @@ pub(crate) struct ConnectArgs {
user: Option<String>,
#[arg(long, short, help = "The VPNC script to use")]
script: Option<String>,
#[arg(long, help = "Connect the server as a gateway, instead of a portal")]
as_gateway: bool,
#[arg(
long,
help = "Use the default CSD wrapper to generate the HIP report and send it to the server"
)]
hip: bool,
#[arg(
short,
long,
help = "Use SSL client certificate file in pkcs#8 (.pem) or pkcs#12 (.p12, .pfx) format"
)]
certificate: Option<String>,
#[arg(short = 'k', long, help = "Use SSL private key file in pkcs#8 (.pem) format")]
sslkey: Option<String>,
#[arg(short = 'p', long, help = "The key passphrase of the private key")]
key_password: Option<String>,
#[arg(long, help = "Same as the '--csd-user' option in the openconnect command")]
csd_user: Option<String>,
@@ -37,6 +59,13 @@ pub(crate) struct ConnectArgs {
#[arg(long, help = "Same as the '--csd-wrapper' option in the openconnect command")]
csd_wrapper: Option<String>,
#[arg(long, default_value = "300", help = "Reconnection retry timeout in seconds")]
reconnect_timeout: u32,
#[arg(short, long, help = "Request MTU from server (legacy servers only)")]
mtu: Option<u32>,
#[arg(long, help = "Do not ask for IPv6 connectivity")]
disable_ipv6: bool,
#[arg(long, default_value = GP_USER_AGENT, help = "The user agent to use")]
user_agent: String,
#[arg(long, default_value = "Linux")]
@@ -47,6 +76,8 @@ pub(crate) struct ConnectArgs {
hidpi: bool,
#[arg(long, help = "Do not reuse the remembered authentication cookie")]
clean: bool,
#[arg(long, help = "Use the default browser to authenticate")]
default_browser: bool,
}
impl ConnectArgs {
@@ -66,11 +97,16 @@ impl ConnectArgs {
pub(crate) struct ConnectHandler<'a> {
args: &'a ConnectArgs,
shared_args: &'a SharedArgs,
latest_key_password: RefCell<Option<String>>,
}
impl<'a> ConnectHandler<'a> {
pub(crate) fn new(args: &'a ConnectArgs, shared_args: &'a SharedArgs) -> Self {
Self { args, shared_args }
Self {
args,
shared_args,
latest_key_password: Default::default(),
}
}
fn build_gp_params(&self) -> GpParams {
@@ -79,11 +115,52 @@ impl<'a> ConnectHandler<'a> {
.client_os(ClientOs::from(&self.args.os))
.os_version(self.args.os_version())
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.certificate(self.args.certificate.clone())
.sslkey(self.args.sslkey.clone())
.key_password(self.latest_key_password.borrow().clone())
.build()
}
pub(crate) async fn handle(&self) -> anyhow::Result<()> {
self.latest_key_password.replace(self.args.key_password.clone());
loop {
let Err(err) = self.handle_impl().await else {
return Ok(());
};
let Some(root_cause) = err.root_cause().downcast_ref::<RequestIdentityError>() else {
return Err(err);
};
match root_cause {
RequestIdentityError::NoKey => {
eprintln!("ERROR: No private key found in the certificate file");
eprintln!("ERROR: Please provide the private key file using the `-k` option");
return Ok(());
}
RequestIdentityError::NoPassphrase(cert_type) | RequestIdentityError::DecryptError(cert_type) => {
// Decrypt the private key error, ask for the key password
let message = format!("Enter the {} passphrase:", cert_type);
let password = Password::new(&message)
.without_confirmation()
.with_display_mode(PasswordDisplayMode::Masked)
.prompt()?;
self.latest_key_password.replace(Some(password));
}
}
}
}
pub(crate) async fn handle_impl(&self) -> anyhow::Result<()> {
let server = self.args.server.as_str();
let as_gateway = self.args.as_gateway;
if as_gateway {
info!("Treating the server as a gateway");
return self.connect_gateway_with_prelogin(server).await;
}
let Err(err) = self.connect_portal_with_prelogin(server).await else {
return Ok(());
@@ -92,10 +169,15 @@ impl<'a> ConnectHandler<'a> {
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;
}
self.connect_gateway_with_prelogin(server).await?;
Err(err)
eprintln!("\nNOTE: the server may be a gateway, not a portal.");
eprintln!("NOTE: try to use the `--as-gateway` option if you were authenticated twice.");
Ok(())
} else {
Err(err)
}
}
async fn connect_portal_with_prelogin(&self, portal: &str) -> anyhow::Result<()> {
@@ -109,16 +191,19 @@ impl<'a> ConnectHandler<'a> {
let selected_gateway = match &self.args.gateway {
Some(gateway) => portal_config
.find_gateway(gateway)
.ok_or_else(|| anyhow::anyhow!("Cannot find gateway {}", gateway))?,
.ok_or_else(|| anyhow::anyhow!("Cannot find gateway specified: {}", gateway))?,
None => {
portal_config.sort_gateways(prelogin.region());
let gateways = portal_config.gateways();
if gateways.len() > 1 {
Select::new("Which gateway do you want to connect to?", gateways)
let gateway = Select::new("Which gateway do you want to connect to?", gateways)
.with_vim_mode(true)
.prompt()?
.prompt()?;
info!("Connecting to the selected gateway: {}", gateway);
gateway
} else {
info!("Connecting to the only available gateway: {}", gateways[0]);
gateways[0]
}
}
@@ -127,7 +212,7 @@ impl<'a> ConnectHandler<'a> {
let gateway = selected_gateway.server();
let cred = portal_config.auth_cookie().into();
let cookie = match gateway_login(gateway, &cred, &gp_params).await {
let cookie = match self.login_gateway(gateway, &cred, &gp_params).await {
Ok(cookie) => cookie,
Err(err) => {
info!("Gateway login failed: {}", err);
@@ -139,26 +224,59 @@ impl<'a> ConnectHandler<'a> {
}
async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> {
info!("Performing the gateway authentication...");
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?;
let cookie = self.login_gateway(gateway, &cred, &gp_params).await?;
self.connect_gateway(gateway, &cookie).await
}
async fn login_gateway(&self, gateway: &str, cred: &Credential, gp_params: &GpParams) -> anyhow::Result<String> {
let mut gp_params = gp_params.clone();
loop {
match gateway_login(gateway, cred, &gp_params).await? {
GatewayLogin::Cookie(cookie) => return Ok(cookie),
GatewayLogin::Mfa(message, input_str) => {
let otp = Text::new(&message).prompt()?;
gp_params.set_input_str(&input_str);
gp_params.set_otp(&otp);
info!("Retrying gateway login with MFA...");
}
}
}
}
async fn connect_gateway(&self, gateway: &str, cookie: &str) -> anyhow::Result<()> {
let mtu = self.args.mtu.unwrap_or(0);
let csd_uid = get_csd_uid(&self.args.csd_user)?;
let csd_wrapper = if self.args.csd_wrapper.is_some() {
self.args.csd_wrapper.clone()
} else if self.args.hip {
find_csd_wrapper()
} else {
None
};
let vpn = Vpn::builder(gateway, cookie)
.user_agent(self.args.user_agent.clone())
.script(self.args.script.clone())
.user_agent(self.args.user_agent.clone())
.certificate(self.args.certificate.clone())
.sslkey(self.args.sslkey.clone())
.key_password(self.latest_key_password.borrow().clone())
.csd_uid(csd_uid)
.csd_wrapper(self.args.csd_wrapper.clone())
.build();
.csd_wrapper(csd_wrapper)
.reconnect_timeout(self.args.reconnect_timeout)
.mtu(mtu)
.disable_ipv6(self.args.disable_ipv6)
.build()?;
let vpn = Arc::new(vpn);
let vpn_clone = vpn.clone();
@@ -185,7 +303,9 @@ impl<'a> ConnectHandler<'a> {
match prelogin {
Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server)
let use_default_browser = prelogin.support_default_browser() && self.args.default_browser;
let cred = SamlAuthLauncher::new(&self.args.server)
.gateway(is_gateway)
.saml_request(prelogin.saml_request())
.user_agent(&self.args.user_agent)
@@ -195,8 +315,21 @@ impl<'a> ConnectHandler<'a> {
.fix_openssl(self.shared_args.fix_openssl)
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.clean(self.args.clean)
.default_browser(use_default_browser)
.launch()
.await
.await?;
if let Some(cred) = cred {
return Ok(cred);
}
if !use_default_browser {
// This should never happen
unreachable!("SAML authentication failed without using the default browser");
}
info!("Waiting for the browser authentication to complete...");
wait_credentials().await
}
Prelogin::Standard(prelogin) => {
let prefix = if is_gateway { "Gateway" } else { "Portal" };
@@ -219,6 +352,27 @@ impl<'a> ConnectHandler<'a> {
}
}
async fn wait_credentials() -> anyhow::Result<Credential> {
// Start a local server to receive the browser authentication data
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
// Write the port to a file
fs::write(GP_CLIENT_PORT_FILE, port.to_string())?;
info!("Listening authentication data on port {}", port);
let (mut socket, _) = listener.accept().await?;
info!("Received the browser authentication data from the socket");
let mut data = String::new();
socket.read_to_string(&mut data).await?;
// Remove the port file
fs::remove_file(GP_CLIENT_PORT_FILE)?;
Credential::from_gpcallback(&data)
}
fn write_pid_file() {
let pid = std::process::id();

View File

@@ -1,4 +1,4 @@
use std::{collections::HashMap, fs, path::PathBuf};
use std::{collections::HashMap, env::temp_dir, fs, path::PathBuf};
use clap::Args;
use directories::ProjectDirs;
@@ -7,6 +7,9 @@ use gpapi::{
utils::{endpoint::http_endpoint, env_file, shutdown_signal},
};
use log::info;
use tokio::io::AsyncWriteExt;
use crate::GP_CLIENT_PORT_FILE;
#[derive(Args)]
pub(crate) struct LaunchGuiArgs {
@@ -78,11 +81,21 @@ impl<'a> LaunchGuiHandler<'a> {
}
async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
let _ = tokio::join!(feed_auth_data_gui(auth_data), feed_auth_data_cli(auth_data));
// Cleanup the temporary file
let html_file = temp_dir().join("gpauth.html");
let _ = std::fs::remove_file(html_file);
Ok(())
}
async fn feed_auth_data_gui(auth_data: &str) -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;
reqwest::Client::default()
.post(format!("{}/auth-data", service_endpoint))
.json(&auth_data)
.body(auth_data.to_string())
.send()
.await?
.error_for_status()?;
@@ -90,6 +103,15 @@ async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
Ok(())
}
async fn feed_auth_data_cli(auth_data: &str) -> anyhow::Result<()> {
let port = tokio::fs::read_to_string(GP_CLIENT_PORT_FILE).await?;
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port.trim())).await?;
stream.write_all(auth_data.as_bytes()).await?;
Ok(())
}
async fn try_active_gui() -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;

View File

@@ -4,6 +4,7 @@ mod disconnect;
mod launch_gui;
pub(crate) const GP_CLIENT_LOCK_FILE: &str = "/var/run/gpclient.lock";
pub(crate) const GP_CLIENT_PORT_FILE: &str = "/var/run/gpclient.port";
#[tokio::main]
async fn main() {

View File

@@ -0,0 +1,36 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"prettier",
],
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint", "react"],
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-unused-vars": "warn",
},
};

25
apps/gpgui-helper/.gitignore vendored Normal file
View File

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

View File

View File

@@ -0,0 +1,3 @@
{
"printWidth": 100
}

View File

@@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GlobalProtect</title>
</head>
<body>
<script>
/* workaround to webview font size auto scaling */
var htmlFontSize = getComputedStyle(document.documentElement).fontSize;
var ratio = parseInt(htmlFontSize, 10) / 16;
document.documentElement.style.fontSize = 16 / ratio + "px";
</script>
<div id="root" data-tauri-drag-region></div>
<script type="module" src="/src/pages/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"name": "gpgui",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.18",
"@mui/material": "^5.14.18",
"@tauri-apps/api": "^1.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.5.6",
"@types/node": "^20.8.10",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-react": "^4.0.3",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "3.1.0",
"typescript": "^5.0.2",
"vite": "^4.5.3"
}
}

3094
apps/gpgui-helper/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -0,0 +1,25 @@
[package]
name = "gpgui-helper"
authors.workspace = true
version.workspace = true
edition.workspace = true
license.workspace = true
[build-dependencies]
tauri-build = { version = "1.5", features = [] }
[dependencies]
gpapi = { path = "../../../crates/gpapi", features = ["tauri"] }
tauri = { workspace = true, features = ["window-start-dragging"] }
tokio.workspace = true
anyhow.workspace = true
log.workspace = true
clap.workspace = true
compile-time.workspace = true
env_logger.workspace = true
futures-util.workspace = true
tempfile.workspace = true
reqwest = { workspace = true, features = ["stream"] }
[features]
custom-protocol = ["tauri/custom-protocol"]

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 96 96"
style="enable-background:new 0 0 96 96;"
xml:space="preserve"
sodipodi:docname="com.yuezk.qt.gpclient.svg"
inkscape:version="0.92.4 5da689c313, 2019-01-14"><metadata
id="metadata14"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs12" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1006"
id="namedview10"
showgrid="false"
inkscape:zoom="6.9532168"
inkscape:cx="7.9545315"
inkscape:cy="59.062386"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g8499" />
<style
type="text/css"
id="style2">
.st0{fill:#2980B9;}
.st1{fill:#3498DB;}
.st2{fill:#2ECC71;}
.st3{fill:#27AE60;}
</style>
<g
id="g8499"
transform="matrix(1.3407388,0,0,1.3407388,-16.409202,-16.355463)"><g
id="XMLID_1_">
<circle
r="32.5"
cy="48"
cx="48"
class="st0"
id="XMLID_3_"
style="fill:#2980b9" />
<path
d="m 48,15.5 v 65 C 65.9,80.5 80.5,65.7 80.5,48 80.5,30 65.9,15.5 48,15.5 Z"
class="st1"
id="XMLID_4_"
inkscape:connector-curvature="0"
style="fill:#3498db" />
<path
d="m 48,15.5 v 0.6 l 1.2,-0.3 c 0.3,-0.3 0.4,-0.3 0.6,-0.3 h -1.1 z m 7.3,0.9 c -0.1,0 0.4,0.9 1.1,1.8 0.8,1.5 1.1,2.1 1.3,2.1 0.3,-0.3 1.9,-1.2 3,-2.1 -1.7,-0.9 -3.5,-1.5 -5.4,-1.8 z m 10.3,6.2 c -0.1,0 -0.4,0 -0.9,0.6 l -0.8,0.9 0.6,0.6 c 0.3,0.6 0.8,0.9 1,1.2 0.5,0.6 0.6,0.6 0.1,1.5 -0.2,0.6 -0.3,0.9 -0.3,0.9 0.1,0.3 0.3,0.3 1.4,0.3 h 1.6 c 0.1,0 0.3,-0.6 0.4,-1.2 l 0.1,-0.9 -1.1,-0.9 c -1,-0.9 -1,-0.9 -1.4,-1.8 -0.3,-0.6 -0.6,-1.2 -0.7,-1.2 z m -3,2.4 c -0.2,0 -1.3,2.1 -1.3,2.4 0,0 0.3,0.6 0.7,0.9 0.4,0.3 0.7,0.6 0.7,0.6 0.1,0 1.2,-1.2 1.4,-1.5 C 64.2,27.1 64,26.8 63.5,26.2 63.1,25.5 62.7,25 62.6,25 Z m 9.5,1.1 0.2,0.3 c 0,0.3 -0.7,0.9 -1.4,1.5 -1.2,0.9 -1.4,1.2 -2,1.2 -0.6,0 -0.9,0.3 -1.8,0.9 -0.6,0.6 -1.2,0.9 -1.2,1.2 0,0 0.2,0.3 0.6,0.9 0.7,0.6 0.7,0.9 0.2,1.8 l -0.4,0.3 h -1.1 c -0.6,0 -1.5,0 -1.8,-0.3 -0.9,0 -0.8,0 -0.1,2.1 1,3 1.1,3.2 1.3,3.2 0.1,0 1.3,-1.2 2.8,-2.4 1.5,-1.2 2.7,-2.4 2.8,-2.4 l 0.6,0.3 c 0.4,0.3 0.5,0 1.3,-0.6 l 0.8,-0.6 0.8,0.6 c 1.9,1.2 2.2,1.5 2.3,2.4 0.2,1.5 0.3,1.8 0.5,1.8 0.1,0 1.3,-1.5 1.6,-1.8 0.1,-0.3 -0.1,-0.6 -1.1,-2.1 -0.7,-0.9 -1.1,-1.8 -1.1,-2.1 0,0 0.1,0 0.3,-0.3 0.2,0 0.4,0.3 1,0.9 -1.6,-2.3 -3.2,-4.7 -5.1,-6.8 z m 2.8,10.7 c -0.2,0 -0.9,0.9 -0.8,1.2 l 0.5,0.3 H 75 c 0.2,0 0.3,0 0.2,-0.3 C 75.1,37.4 75,36.8 74.9,36.8 Z M 72.3,38 h -2.4 l -2.4,0.3 -4.5,3.5 -4.4,3.8 v 3.5 c 0,2.1 0,3.8 0.1,3.8 0.1,0 0.7,0.9 1.5,1.5 0.8,0.9 1.5,1.5 1.8,1.8 0.4,0.3 0.5,0.3 4,0.6 l 3.4,0.3 1.6,0.9 c 0.8,0.6 1.5,1.2 1.6,1.2 0.1,0 -0.3,0.3 -0.6,0.6 l -0.6,0.6 1,1.2 c 0.5,0.6 1.3,1.5 1.7,1.8 l 0.6,0.9 v 1.7 0.9 c 3.7,-5 5.9,-11.5 6.1,-18.3 0.1,-2.7 -0.3,-5.3 -0.8,-8 l -0.6,-0.3 c -0.1,0 -0.5,0.3 -1,0.6 -0.5,0.3 -1,0.9 -1.1,0.9 -0.1,0 -0.8,-0.3 -1.8,-0.6 l -1.8,-0.6 v -0.9 c 0,-0.6 0,-0.9 -0.6,-1.5 z M 48,63.7 V 64 h 0.2 z"
class="st2"
id="XMLID_13_"
inkscape:connector-curvature="0"
style="fill:#2ecc71" />
<path
d="m 48,15.5 c -3.1,0 -6.2,0.5 -9,1.3 0.3,0.4 0.3,0.4 0.6,0.9 1.5,2.5 1.7,2.8 2.1,2.9 0.3,0 0.9,0.1 1.6,0.1 h 1.2 l 0.9,-2 0.8,-1.9 1.8,-0.6 z m -16.9,4.7 c -2.8,1.7 -5.4,3.9 -7.6,6.4 -3.8,4.3 -6.3,9.6 -7.4,15.4 0.5,0 0.9,-0.1 1.8,-0.1 2.8,0.1 2.5,0 3.4,1.4 0.5,0.8 0.6,0.8 1.4,0.8 1,0.1 0.9,0 0.5,-1.6 -0.2,-0.6 -0.3,-1.2 -0.3,-1.4 0,-0.2 0.5,-0.7 1.7,-1.6 1.9,-1.5 1.8,-1.3 1.5,-2.9 -0.1,-0.3 0.1,-0.6 0.6,-1.2 0.7,-0.7 0.7,-0.6 1.4,-0.6 h 0.7 l 0.1,-1.2 c 0.1,-0.7 0.1,-1.3 0.2,-1.3 0,0 1.9,-1.1 4.1,-2.3 2.2,-1.2 4.1,-2.2 4.2,-2.3 0.2,-0.2 -0.3,-0.8 -2.7,-3.8 -1.5,-1.9 -2.8,-3.6 -2.9,-3.7 z m -5.8,23 c -0.1,0 -0.1,0.3 -0.1,0.6 0,0.6 0,0.7 0.6,1 0.8,0.4 0.9,0.5 0.8,0.2 -0.1,-0.4 -1.2,-1.9 -1.3,-1.8 z m -3.4,2.1 -0.5,1.8 c 0.1,0.1 0.9,0.3 1.8,0.5 1,0.2 1.6,0.4 1.8,0.3 l 0.5,-1.3 z m -3.8,1 -1.1,0.6 c -0.6,0.3 -1.2,0.6 -1.4,0.6 h -0.1 c 0,1.4 0.1,2.8 0.3,4.2 l 0.6,0.4 1,-0.1 h 1 l 0.6,1.4 c 0.3,0.7 0.7,1.4 0.8,1.5 0.1,0.1 1,0.1 1.8,0.1 h 1.5 L 23,56.2 c 0,1.2 0,1.3 -0.6,2.2 -0.4,0.5 -0.6,1.2 -0.6,1.4 0,0.2 0.7,2.1 1.6,4.3 l 1.5,4 1.6,0.8 c 1.2,0.6 1.5,0.8 1.5,1 0,0.1 -0.4,2.1 -0.6,3.1 3,2.5 6.4,4.5 10.2,5.8 3.5,-3.6 6.8,-7.1 7.3,-7.6 l 0.7,-0.7 0.2,-1.9 c 0.2,-1.1 0.4,-2.1 0.4,-2.2 0,-0.1 0.5,-0.6 1,-1.2 0.5,-0.5 0.8,-1 0.8,-1.1 v -0.2 c -0.1,-0.1 -1.4,-1.1 -3,-2.2 l -3.1,-2.1 -1.1,-0.1 c -0.8,0 -1.2,0 -1.3,-0.2 C 39.4,59.2 39.2,58.5 39.1,57.7 39,56.9 38.9,56.2 38.8,56.1 38.8,56 38,56 37.1,56 36.2,56 35.4,55.9 35.3,55.8 35.2,55.7 35.2,55.1 35.1,54.3 35,53.6 34.9,53 34.8,52.9 34.7,52.8 33.7,52.7 32.5,52.6 30.5,52.5 30.1,52.5 29.1,52 l -1.2,-0.6 -1.6,0.7 -1.7,0.9 -1.8,-0.1 c -2,0 -1.9,0.2 -2.1,-1.6 C 20.6,50.7 20.6,50.1 20.5,50.1 20.4,50 20,50 19.6,49.9 L 18.9,49.7 19,49.2 c 0,-0.3 0,-1 0.1,-1.4 L 19.2,47 18.7,46.5 Z m 9.1,1.1 C 27.1,47.5 27.1,47.8 27,48 l -0.1,0.5 2.9,1.2 c 2.9,1.1 3.4,1.2 3.9,0.7 0.2,-0.2 0.1,-0.2 -0.3,-0.4 -0.3,-0.1 -1.7,-0.9 -3.2,-1.6 -1.7,-0.7 -2.9,-1.1 -3,-1 z"
class="st3"
id="XMLID_20_"
inkscape:connector-curvature="0"
style="fill:#27ae60" />
</g><g
transform="matrix(1.458069,0,0,1.458069,-22.631538,-19.615144)"
id="g7664"><path
inkscape:connector-curvature="0"
id="XMLID_6_"
class="st3"
d="m 38.8,56.1 c 0,1.2 1,2.2 2.2,2.2 h 15.2 c 1.2,0 2.2,-1 2.2,-2.2 V 45.3 c 0,-1.2 -1,-2.2 -2.2,-2.2 H 40.9 c -1.2,0 -2.2,1 -2.2,2.2 v 10.8 z"
style="fill:#f1aa27;fill-opacity:1" /><path
style="fill:#e6e6e6"
inkscape:connector-curvature="0"
id="XMLID_7_"
class="st4"
d="m 55.5,43.1 h -3.3 v -3.7 c 0,-2.1 -1.7,-3.8 -3.8,-3.8 -2.1,0 -3.8,1.7 -3.8,3.8 v 3.8 h -3.1 v -3.8 c 0,-3.9 3.2,-7 7,-7 3.9,0 7,3.2 7,7 z" /><path
style="fill:#e6e6e6;fill-opacity:1"
inkscape:connector-curvature="0"
id="XMLID_8_"
class="st5"
d="m 50.35,48.2 c 0,-1 -0.8,-1.8 -1.8,-1.8 -1,0 -1.8,0.8 -1.8,1.8 0,0.7 0.4,1.3 1,1.6 l -1,5.2 h 3.6 l -1,-5.2 c 0.6,-0.3 1,-0.9 1,-1.6 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,56 @@
use std::sync::Arc;
use gpapi::utils::window::WindowExt;
use log::info;
use tauri::Manager;
use crate::updater::{GuiUpdater, Installer, ProgressNotifier};
pub struct App {
api_key: Vec<u8>,
gui_version: String,
}
impl App {
pub fn new(api_key: Vec<u8>, gui_version: &str) -> Self {
Self {
api_key,
gui_version: gui_version.to_string(),
}
}
pub fn run(&self) -> anyhow::Result<()> {
let gui_version = self.gui_version.clone();
let api_key = self.api_key.clone();
tauri::Builder::default()
.setup(move |app| {
let win = app.get_window("main").expect("no main window");
win.hide_menu();
let notifier = ProgressNotifier::new(win.clone());
let installer = Installer::new(api_key);
let updater = Arc::new(GuiUpdater::new(gui_version, notifier, installer));
let win_clone = win.clone();
app.listen_global("app://update-done", move |_event| {
info!("Update done");
let _ = win_clone.close();
});
// Listen for the update event
win.listen("app://update", move |_event| {
let updater = Arc::clone(&updater);
tokio::spawn(async move { updater.update().await });
});
// Update the GUI on startup
win.trigger("app://update", None);
Ok(())
})
.run(tauri::generate_context!())?;
Ok(())
}
}

View File

@@ -0,0 +1,56 @@
use clap::Parser;
use gpapi::utils::base64;
use log::{info, LevelFilter};
use crate::app::App;
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
const GP_API_KEY: &[u8; 32] = &[0; 32];
#[derive(Parser)]
#[command(version = VERSION)]
struct Cli {
#[arg(long, help = "Read the API key from stdin")]
api_key_on_stdin: bool,
#[arg(long, default_value = env!("CARGO_PKG_VERSION"), help = "The version of the GUI")]
gui_version: String,
}
impl Cli {
fn run(&self) -> anyhow::Result<()> {
let api_key = self.read_api_key()?;
let app = App::new(api_key, &self.gui_version);
app.run()
}
fn read_api_key(&self) -> anyhow::Result<Vec<u8>> {
if self.api_key_on_stdin {
let mut api_key = String::new();
std::io::stdin().read_line(&mut api_key)?;
let api_key = base64::decode_to_vec(api_key.trim())?;
Ok(api_key)
} else {
Ok(GP_API_KEY.to_vec())
}
}
}
fn init_logger() {
env_logger::builder().filter_level(LevelFilter::Info).init();
}
pub fn run() {
let cli = Cli::parse();
init_logger();
info!("gpgui-helper started: {}", VERSION);
if let Err(e) = cli.run() {
eprintln!("{}", e);
std::process::exit(1);
}
}

View File

@@ -0,0 +1,87 @@
use std::io::Write;
use anyhow::bail;
use futures_util::StreamExt;
use log::info;
use tempfile::NamedTempFile;
use tokio::sync::RwLock;
type OnProgress = Box<dyn Fn(Option<f64>) + Send + Sync + 'static>;
pub struct FileDownloader<'a> {
url: &'a str,
on_progress: RwLock<Option<OnProgress>>,
}
impl<'a> FileDownloader<'a> {
pub fn new(url: &'a str) -> Self {
Self {
url,
on_progress: Default::default(),
}
}
pub fn on_progress<T>(&self, on_progress: T)
where
T: Fn(Option<f64>) + Send + Sync + 'static,
{
if let Ok(mut guard) = self.on_progress.try_write() {
*guard = Some(Box::new(on_progress));
} else {
info!("Failed to acquire on_progress lock");
}
}
pub async fn download(&self) -> anyhow::Result<NamedTempFile> {
let res = reqwest::get(self.url).await?.error_for_status()?;
let content_length = res.content_length().unwrap_or(0);
info!("Content length: {}", content_length);
let mut current_length = 0;
let mut stream = res.bytes_stream();
let mut file = NamedTempFile::new()?;
while let Some(item) = stream.next().await {
let chunk = item?;
let chunk_size = chunk.len() as u64;
file.write_all(&chunk)?;
current_length += chunk_size;
let progress = current_length as f64 / content_length as f64 * 100.0;
if let Some(on_progress) = &*self.on_progress.read().await {
let progress = if content_length > 0 { Some(progress) } else { None };
on_progress(progress);
}
}
if content_length > 0 && current_length != content_length {
bail!("Download incomplete");
}
info!("Downloaded to: {:?}", file.path());
Ok(file)
}
}
pub struct ChecksumFetcher<'a> {
url: &'a str,
}
impl<'a> ChecksumFetcher<'a> {
pub fn new(url: &'a str) -> Self {
Self { url }
}
pub async fn fetch(&self) -> anyhow::Result<String> {
let res = reqwest::get(self.url).await?.error_for_status()?;
let checksum = res.text().await?.trim().to_string();
Ok(checksum)
}
}

View File

@@ -0,0 +1,5 @@
pub(crate) mod app;
pub(crate) mod downloader;
pub(crate) mod updater;
pub mod cli;

View File

@@ -0,0 +1,9 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use gpgui_helper::cli;
#[tokio::main]
async fn main() {
cli::run()
}

View File

@@ -0,0 +1,147 @@
use std::sync::Arc;
use gpapi::{
service::request::UpdateGuiRequest,
utils::{checksum::verify_checksum, crypto::Crypto, endpoint::http_endpoint},
};
use log::{info, warn};
use tauri::{Manager, Window};
use crate::downloader::{ChecksumFetcher, FileDownloader};
#[cfg(not(debug_assertions))]
const SNAPSHOT: &str = match option_env!("SNAPSHOT") {
Some(val) => val,
None => "false"
};
pub struct ProgressNotifier {
win: Window,
}
impl ProgressNotifier {
pub fn new(win: Window) -> Self {
Self { win }
}
fn notify(&self, progress: Option<f64>) {
let _ = self.win.emit_all("app://update-progress", progress);
}
fn notify_error(&self) {
let _ = self.win.emit_all("app://update-error", ());
}
fn notify_done(&self) {
let _ = self.win.emit_and_trigger("app://update-done", ());
}
}
pub struct Installer {
crypto: Crypto,
}
impl Installer {
pub fn new(api_key: Vec<u8>) -> Self {
Self {
crypto: Crypto::new(api_key),
}
}
async fn install(&self, path: &str, checksum: &str) -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;
let request = UpdateGuiRequest {
path: path.to_string(),
checksum: checksum.to_string(),
};
let payload = self.crypto.encrypt(&request)?;
reqwest::Client::default()
.post(format!("{}/update-gui", service_endpoint))
.body(payload)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
pub struct GuiUpdater {
version: String,
notifier: Arc<ProgressNotifier>,
installer: Installer,
}
impl GuiUpdater {
pub fn new(version: String, notifier: ProgressNotifier, installer: Installer) -> Self {
Self {
version,
notifier: Arc::new(notifier),
installer,
}
}
pub async fn update(&self) {
info!("Update GUI, version: {}", self.version);
#[cfg(debug_assertions)]
let release_tag = "snapshot";
#[cfg(not(debug_assertions))]
let release_tag = if SNAPSHOT == "true" {
String::from("snapshot")
} else {
format!("v{}", self.version)
};
#[cfg(target_arch = "x86_64")]
let arch = "x86_64";
#[cfg(target_arch = "aarch64")]
let arch = "aarch64";
let file_url = format!(
"https://github.com/yuezk/GlobalProtect-openconnect/releases/download/{}/gpgui_{}.bin.tar.xz",
release_tag, arch
);
let checksum_url = format!("{}.sha256", file_url);
info!("Downloading file: {}", file_url);
let dl = FileDownloader::new(&file_url);
let cf = ChecksumFetcher::new(&checksum_url);
let notifier = Arc::clone(&self.notifier);
dl.on_progress(move |progress| notifier.notify(progress));
let res = tokio::try_join!(dl.download(), cf.fetch());
let (file, checksum) = match res {
Ok((file, checksum)) => (file, checksum),
Err(err) => {
warn!("Download error: {}", err);
self.notifier.notify_error();
return;
}
};
let path = file.into_temp_path();
let file_path = path.to_string_lossy();
if let Err(err) = verify_checksum(&file_path, &checksum) {
warn!("Checksum error: {}", err);
self.notifier.notify_error();
return;
}
info!("Checksum success");
if let Err(err) = self.installer.install(&file_path, &checksum).await {
warn!("Install error: {}", err);
self.notifier.notify_error();
} else {
info!("Install success");
self.notifier.notify_done();
}
}
}

View File

@@ -0,0 +1,52 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1421",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "gpgui-helper"
},
"tauri": {
"allowlist": {
"all": false,
"window": {
"all": false,
"startDragging": true
}
},
"bundle": {
"active": false,
"targets": "deb",
"identifier": "com.yuezk.gpgui-helper",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"security": {
"csp": null
},
"windows": [
{
"title": "GlobalProtect GUI Helper",
"center": true,
"resizable": true,
"width": 500,
"height": 100,
"minWidth": 500,
"minHeight": 100,
"maxWidth": 500,
"maxHeight": 100,
"label": "main",
"decorations": false
}
]
}
}

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 96 96"
style="enable-background:new 0 0 96 96;"
xml:space="preserve"
sodipodi:docname="com.yuezk.qt.gpclient.svg"
inkscape:version="0.92.4 5da689c313, 2019-01-14"><metadata
id="metadata14"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs12" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1006"
id="namedview10"
showgrid="false"
inkscape:zoom="6.9532168"
inkscape:cx="7.9545315"
inkscape:cy="59.062386"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g8499" />
<style
type="text/css"
id="style2">
.st0{fill:#2980B9;}
.st1{fill:#3498DB;}
.st2{fill:#2ECC71;}
.st3{fill:#27AE60;}
</style>
<g
id="g8499"
transform="matrix(1.3407388,0,0,1.3407388,-16.409202,-16.355463)"><g
id="XMLID_1_">
<circle
r="32.5"
cy="48"
cx="48"
class="st0"
id="XMLID_3_"
style="fill:#2980b9" />
<path
d="m 48,15.5 v 65 C 65.9,80.5 80.5,65.7 80.5,48 80.5,30 65.9,15.5 48,15.5 Z"
class="st1"
id="XMLID_4_"
inkscape:connector-curvature="0"
style="fill:#3498db" />
<path
d="m 48,15.5 v 0.6 l 1.2,-0.3 c 0.3,-0.3 0.4,-0.3 0.6,-0.3 h -1.1 z m 7.3,0.9 c -0.1,0 0.4,0.9 1.1,1.8 0.8,1.5 1.1,2.1 1.3,2.1 0.3,-0.3 1.9,-1.2 3,-2.1 -1.7,-0.9 -3.5,-1.5 -5.4,-1.8 z m 10.3,6.2 c -0.1,0 -0.4,0 -0.9,0.6 l -0.8,0.9 0.6,0.6 c 0.3,0.6 0.8,0.9 1,1.2 0.5,0.6 0.6,0.6 0.1,1.5 -0.2,0.6 -0.3,0.9 -0.3,0.9 0.1,0.3 0.3,0.3 1.4,0.3 h 1.6 c 0.1,0 0.3,-0.6 0.4,-1.2 l 0.1,-0.9 -1.1,-0.9 c -1,-0.9 -1,-0.9 -1.4,-1.8 -0.3,-0.6 -0.6,-1.2 -0.7,-1.2 z m -3,2.4 c -0.2,0 -1.3,2.1 -1.3,2.4 0,0 0.3,0.6 0.7,0.9 0.4,0.3 0.7,0.6 0.7,0.6 0.1,0 1.2,-1.2 1.4,-1.5 C 64.2,27.1 64,26.8 63.5,26.2 63.1,25.5 62.7,25 62.6,25 Z m 9.5,1.1 0.2,0.3 c 0,0.3 -0.7,0.9 -1.4,1.5 -1.2,0.9 -1.4,1.2 -2,1.2 -0.6,0 -0.9,0.3 -1.8,0.9 -0.6,0.6 -1.2,0.9 -1.2,1.2 0,0 0.2,0.3 0.6,0.9 0.7,0.6 0.7,0.9 0.2,1.8 l -0.4,0.3 h -1.1 c -0.6,0 -1.5,0 -1.8,-0.3 -0.9,0 -0.8,0 -0.1,2.1 1,3 1.1,3.2 1.3,3.2 0.1,0 1.3,-1.2 2.8,-2.4 1.5,-1.2 2.7,-2.4 2.8,-2.4 l 0.6,0.3 c 0.4,0.3 0.5,0 1.3,-0.6 l 0.8,-0.6 0.8,0.6 c 1.9,1.2 2.2,1.5 2.3,2.4 0.2,1.5 0.3,1.8 0.5,1.8 0.1,0 1.3,-1.5 1.6,-1.8 0.1,-0.3 -0.1,-0.6 -1.1,-2.1 -0.7,-0.9 -1.1,-1.8 -1.1,-2.1 0,0 0.1,0 0.3,-0.3 0.2,0 0.4,0.3 1,0.9 -1.6,-2.3 -3.2,-4.7 -5.1,-6.8 z m 2.8,10.7 c -0.2,0 -0.9,0.9 -0.8,1.2 l 0.5,0.3 H 75 c 0.2,0 0.3,0 0.2,-0.3 C 75.1,37.4 75,36.8 74.9,36.8 Z M 72.3,38 h -2.4 l -2.4,0.3 -4.5,3.5 -4.4,3.8 v 3.5 c 0,2.1 0,3.8 0.1,3.8 0.1,0 0.7,0.9 1.5,1.5 0.8,0.9 1.5,1.5 1.8,1.8 0.4,0.3 0.5,0.3 4,0.6 l 3.4,0.3 1.6,0.9 c 0.8,0.6 1.5,1.2 1.6,1.2 0.1,0 -0.3,0.3 -0.6,0.6 l -0.6,0.6 1,1.2 c 0.5,0.6 1.3,1.5 1.7,1.8 l 0.6,0.9 v 1.7 0.9 c 3.7,-5 5.9,-11.5 6.1,-18.3 0.1,-2.7 -0.3,-5.3 -0.8,-8 l -0.6,-0.3 c -0.1,0 -0.5,0.3 -1,0.6 -0.5,0.3 -1,0.9 -1.1,0.9 -0.1,0 -0.8,-0.3 -1.8,-0.6 l -1.8,-0.6 v -0.9 c 0,-0.6 0,-0.9 -0.6,-1.5 z M 48,63.7 V 64 h 0.2 z"
class="st2"
id="XMLID_13_"
inkscape:connector-curvature="0"
style="fill:#2ecc71" />
<path
d="m 48,15.5 c -3.1,0 -6.2,0.5 -9,1.3 0.3,0.4 0.3,0.4 0.6,0.9 1.5,2.5 1.7,2.8 2.1,2.9 0.3,0 0.9,0.1 1.6,0.1 h 1.2 l 0.9,-2 0.8,-1.9 1.8,-0.6 z m -16.9,4.7 c -2.8,1.7 -5.4,3.9 -7.6,6.4 -3.8,4.3 -6.3,9.6 -7.4,15.4 0.5,0 0.9,-0.1 1.8,-0.1 2.8,0.1 2.5,0 3.4,1.4 0.5,0.8 0.6,0.8 1.4,0.8 1,0.1 0.9,0 0.5,-1.6 -0.2,-0.6 -0.3,-1.2 -0.3,-1.4 0,-0.2 0.5,-0.7 1.7,-1.6 1.9,-1.5 1.8,-1.3 1.5,-2.9 -0.1,-0.3 0.1,-0.6 0.6,-1.2 0.7,-0.7 0.7,-0.6 1.4,-0.6 h 0.7 l 0.1,-1.2 c 0.1,-0.7 0.1,-1.3 0.2,-1.3 0,0 1.9,-1.1 4.1,-2.3 2.2,-1.2 4.1,-2.2 4.2,-2.3 0.2,-0.2 -0.3,-0.8 -2.7,-3.8 -1.5,-1.9 -2.8,-3.6 -2.9,-3.7 z m -5.8,23 c -0.1,0 -0.1,0.3 -0.1,0.6 0,0.6 0,0.7 0.6,1 0.8,0.4 0.9,0.5 0.8,0.2 -0.1,-0.4 -1.2,-1.9 -1.3,-1.8 z m -3.4,2.1 -0.5,1.8 c 0.1,0.1 0.9,0.3 1.8,0.5 1,0.2 1.6,0.4 1.8,0.3 l 0.5,-1.3 z m -3.8,1 -1.1,0.6 c -0.6,0.3 -1.2,0.6 -1.4,0.6 h -0.1 c 0,1.4 0.1,2.8 0.3,4.2 l 0.6,0.4 1,-0.1 h 1 l 0.6,1.4 c 0.3,0.7 0.7,1.4 0.8,1.5 0.1,0.1 1,0.1 1.8,0.1 h 1.5 L 23,56.2 c 0,1.2 0,1.3 -0.6,2.2 -0.4,0.5 -0.6,1.2 -0.6,1.4 0,0.2 0.7,2.1 1.6,4.3 l 1.5,4 1.6,0.8 c 1.2,0.6 1.5,0.8 1.5,1 0,0.1 -0.4,2.1 -0.6,3.1 3,2.5 6.4,4.5 10.2,5.8 3.5,-3.6 6.8,-7.1 7.3,-7.6 l 0.7,-0.7 0.2,-1.9 c 0.2,-1.1 0.4,-2.1 0.4,-2.2 0,-0.1 0.5,-0.6 1,-1.2 0.5,-0.5 0.8,-1 0.8,-1.1 v -0.2 c -0.1,-0.1 -1.4,-1.1 -3,-2.2 l -3.1,-2.1 -1.1,-0.1 c -0.8,0 -1.2,0 -1.3,-0.2 C 39.4,59.2 39.2,58.5 39.1,57.7 39,56.9 38.9,56.2 38.8,56.1 38.8,56 38,56 37.1,56 36.2,56 35.4,55.9 35.3,55.8 35.2,55.7 35.2,55.1 35.1,54.3 35,53.6 34.9,53 34.8,52.9 34.7,52.8 33.7,52.7 32.5,52.6 30.5,52.5 30.1,52.5 29.1,52 l -1.2,-0.6 -1.6,0.7 -1.7,0.9 -1.8,-0.1 c -2,0 -1.9,0.2 -2.1,-1.6 C 20.6,50.7 20.6,50.1 20.5,50.1 20.4,50 20,50 19.6,49.9 L 18.9,49.7 19,49.2 c 0,-0.3 0,-1 0.1,-1.4 L 19.2,47 18.7,46.5 Z m 9.1,1.1 C 27.1,47.5 27.1,47.8 27,48 l -0.1,0.5 2.9,1.2 c 2.9,1.1 3.4,1.2 3.9,0.7 0.2,-0.2 0.1,-0.2 -0.3,-0.4 -0.3,-0.1 -1.7,-0.9 -3.2,-1.6 -1.7,-0.7 -2.9,-1.1 -3,-1 z"
class="st3"
id="XMLID_20_"
inkscape:connector-curvature="0"
style="fill:#27ae60" />
</g><g
transform="matrix(1.458069,0,0,1.458069,-22.631538,-19.615144)"
id="g7664"><path
inkscape:connector-curvature="0"
id="XMLID_6_"
class="st3"
d="m 38.8,56.1 c 0,1.2 1,2.2 2.2,2.2 h 15.2 c 1.2,0 2.2,-1 2.2,-2.2 V 45.3 c 0,-1.2 -1,-2.2 -2.2,-2.2 H 40.9 c -1.2,0 -2.2,1 -2.2,2.2 v 10.8 z"
style="fill:#f1aa27;fill-opacity:1" /><path
style="fill:#e6e6e6"
inkscape:connector-curvature="0"
id="XMLID_7_"
class="st4"
d="m 55.5,43.1 h -3.3 v -3.7 c 0,-2.1 -1.7,-3.8 -3.8,-3.8 -2.1,0 -3.8,1.7 -3.8,3.8 v 3.8 h -3.1 v -3.8 c 0,-3.9 3.2,-7 7,-7 3.9,0 7,3.2 7,7 z" /><path
style="fill:#e6e6e6;fill-opacity:1"
inkscape:connector-curvature="0"
id="XMLID_8_"
class="st5"
d="m 50.35,48.2 c 0,-1 -0.8,-1.8 -1.8,-1.8 -1,0 -1.8,0.8 -1.8,1.8 0,0.7 0.4,1.3 1,1.6 l -1,5.2 h 3.6 l -1,-5.2 c 0.6,-0.3 1,-0.9 1,-1.6 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,131 @@
import { Box, Button, CssBaseline, LinearProgress, Typography } from "@mui/material";
import { appWindow } from "@tauri-apps/api/window";
import logo from "../../assets/icon.svg";
import { useEffect, useState } from "react";
import "./styles.css";
function useUpdateProgress() {
const [progress, setProgress] = useState<number | null>(null);
useEffect(() => {
const unlisten = appWindow.listen("app://update-progress", (event) => {
setProgress(event.payload as number);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
}, []);
return progress;
}
export default function App() {
const [error, setError] = useState(false);
useEffect(() => {
const unlisten = appWindow.listen("app://update-error", () => {
setError(true);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
}, []);
const handleRetry = () => {
setError(false);
appWindow.emit("app://update");
};
return (
<>
<CssBaseline />
<Box
sx={{ position: "absolute", inset: 0 }}
display="flex"
alignItems="center"
px={2}
data-tauri-drag-region
>
<Box display="flex" alignItems="center" flex="1" data-tauri-drag-region>
<Box
component="img"
src={logo}
alt="logo"
sx={{ width: "4rem", height: "4rem" }}
data-tauri-drag-region
/>
<Box flex={1} ml={2}>
{error ? <DownloadFailed onRetry={handleRetry} /> : <DownloadIndicator />}
</Box>
</Box>
</Box>
</>
);
}
function DownloadIndicator() {
const progress = useUpdateProgress();
return (
<>
<Typography variant="h1" fontSize="1rem" data-tauri-drag-region>
Updating the GUI components...
</Typography>
<Box mt={1}>
<LinearProgressWithLabel value={progress} />
</Box>
</>
);
}
function DownloadFailed({ onRetry }: { onRetry: () => void }) {
return (
<>
<Typography variant="h1" fontSize="1rem" data-tauri-drag-region>
Failed to update the GUI components.
</Typography>
<Box mt={1} data-tauri-drag-region>
<Button
variant="contained"
color="primary"
size="small"
onClick={onRetry}
sx={{
textTransform: "none",
}}
>
Retry
</Button>
</Box>
</>
);
}
function LinearProgressWithLabel(props: { value: number | null }) {
const { value } = props;
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box flex="1">
<LinearProgress
variant={value === null ? "indeterminate" : "determinate"}
value={value ?? 0}
sx={{
py: 1.2,
".MuiLinearProgress-bar": {
transition: "none",
},
}}
/>
</Box>
{value !== null && (
<Box sx={{ minWidth: 35, textAlign: "right", ml: 1 }}>
<Typography variant="body2" color="text.secondary">{`${Math.round(value)}%`}</Typography>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,10 @@
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
-webkit-user-select: none;
user-select: none;
cursor: default;
}

View File

@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import App from "../components/App/App";
const rootApp = createRoot(document.getElementById('root') as HTMLElement);
rootApp.render(<App />);

0
apps/gpgui-helper/src/types.d.ts vendored Normal file
View File

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

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

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

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

View File

@@ -0,0 +1,30 @@
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1421,
strictPort: true,
},
// 3. to make use of `TAURI_DEBUG` and other env variables
// https://tauri.app/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
};
});

View File

@@ -13,7 +13,10 @@ tokio.workspace = true
tokio-util.workspace = true
axum = { workspace = true, features = ["ws"] }
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
env_logger.workspace = true
log.workspace = true
compile-time.workspace = true
xz2 = "0.1"
tar = "0.4"

View File

@@ -112,7 +112,7 @@ fn init_logger() -> Arc<Redaction> {
let timestamp = buf.timestamp();
writeln!(
buf,
"[{} {} {}] {}",
"[{} {} {}] {}",
timestamp,
record.level(),
record.module_path().unwrap_or_default(),
@@ -127,10 +127,8 @@ fn init_logger() -> Arc<Redaction> {
async fn launch_gui(envs: Option<HashMap<String, String>>, api_key: Vec<u8>, mut minimized: bool) {
loop {
let api_key_clone = api_key.clone();
let gui_launcher = GuiLauncher::new()
let gui_launcher = GuiLauncher::new(env!("CARGO_PKG_VERSION"), &api_key)
.envs(envs.clone())
.api_key(api_key_clone)
.minimized(minimized);
match gui_launcher.launch().await {

View File

@@ -1,15 +1,33 @@
use std::{borrow::Cow, ops::ControlFlow, sync::Arc};
use std::{
borrow::Cow,
fs::{File, Permissions},
io::BufReader,
ops::ControlFlow,
os::unix::fs::PermissionsExt,
path::PathBuf,
sync::Arc,
};
use anyhow::bail;
use axum::{
body::Bytes,
extract::{
ws::{self, CloseFrame, Message, WebSocket},
State, WebSocketUpgrade,
},
http::StatusCode,
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use gpapi::service::event::WsEvent;
use gpapi::{
service::{event::WsEvent, request::UpdateGuiRequest},
utils::checksum::verify_checksum,
GP_GUI_BINARY,
};
use log::{info, warn};
use tar::Archive;
use tokio::fs;
use xz2::read::XzDecoder;
use crate::ws_server::WsServerContext;
@@ -25,6 +43,68 @@ pub(crate) async fn auth_data(State(ctx): State<Arc<WsServerContext>>, body: Str
ctx.send_event(WsEvent::AuthData(body)).await;
}
pub async fn update_gui(State(ctx): State<Arc<WsServerContext>>, body: Bytes) -> Result<(), StatusCode> {
let payload = match ctx.decrypt::<UpdateGuiRequest>(body.to_vec()) {
Ok(payload) => payload,
Err(err) => {
warn!("Failed to decrypt update payload: {}", err);
return Err(StatusCode::BAD_REQUEST);
}
};
info!("Update GUI: {:?}", payload);
let UpdateGuiRequest { path, checksum } = payload;
info!("Verifying checksum");
verify_checksum(&path, &checksum).map_err(|err| {
warn!("Failed to verify checksum: {}", err);
StatusCode::BAD_REQUEST
})?;
info!("Installing GUI");
install_gui(&path).await.map_err(|err| {
warn!("Failed to install GUI: {}", err);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(())
}
// Unpack GPGUI archive, gpgui_2.0.0_{arch}.bin.tar.xz and install it
async fn install_gui(src: &str) -> anyhow::Result<()> {
let path = PathBuf::from(GP_GUI_BINARY);
let Some(dir) = path.parent() else {
bail!("Failed to get parent directory of GUI binary");
};
fs::create_dir_all(dir).await?;
// Unpack the archive
info!("Unpacking GUI archive");
let tar = XzDecoder::new(BufReader::new(File::open(src)?));
let mut ar = Archive::new(tar);
for entry in ar.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if let Some(name) = path.file_name() {
let name = name.to_string_lossy();
if name == "gpgui" {
let mut file = File::create(GP_GUI_BINARY)?;
std::io::copy(&mut entry, &mut file)?;
break;
}
}
}
// Make the binary executable
fs::set_permissions(GP_GUI_BINARY, Permissions::from_mode(0o755)).await?;
Ok(())
}
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

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

View File

@@ -4,7 +4,7 @@ use gpapi::service::{
request::{ConnectRequest, WsRequest},
vpn_state::VpnState,
};
use log::info;
use log::{info, warn};
use openconnect::Vpn;
use tokio::sync::{mpsc, oneshot, watch, RwLock};
use tokio_util::sync::CancellationToken;
@@ -31,21 +31,34 @@ impl VpnTaskContext {
return;
}
let vpn_state_tx = self.vpn_state_tx.clone();
let info = req.info().clone();
let vpn_handle = Arc::clone(&self.vpn_handle);
let args = req.args();
let vpn = Vpn::builder(req.gateway().server(), args.cookie())
.user_agent(args.user_agent())
let vpn = match Vpn::builder(req.gateway().server(), args.cookie())
.script(args.vpnc_script())
.user_agent(args.user_agent())
.os(args.openconnect_os())
.certificate(args.certificate())
.sslkey(args.sslkey())
.key_password(args.key_password())
.csd_uid(args.csd_uid())
.csd_wrapper(args.csd_wrapper())
.os(args.openconnect_os())
.build();
.reconnect_timeout(args.reconnect_timeout())
.mtu(args.mtu())
.disable_ipv6(args.disable_ipv6())
.build()
{
Ok(vpn) => vpn,
Err(err) => {
warn!("Failed to create VPN: {}", err);
vpn_state_tx.send(VpnState::Disconnected).ok();
return;
}
};
// Save the VPN handle
vpn_handle.write().await.replace(vpn);
let vpn_state_tx = self.vpn_state_tx.clone();
let connect_info = Box::new(info.clone());
vpn_state_tx.send(VpnState::Connecting(connect_info)).ok();

View File

@@ -6,6 +6,7 @@ use gpapi::{
utils::{crypto::Crypto, lock_file::LockFile, redact::Redaction},
};
use log::{info, warn};
use serde::de::DeserializeOwned;
use tokio::{
net::TcpListener,
sync::{mpsc, watch, RwLock},
@@ -38,6 +39,10 @@ impl WsServerContext {
}
}
pub fn decrypt<T: DeserializeOwned>(&self, encrypted: Vec<u8>) -> anyhow::Result<T> {
self.crypto.decrypt(encrypted)
}
pub async fn send_event(&self, event: WsEvent) {
let connections = self.connections.read().await;
@@ -113,28 +118,41 @@ impl WsServer {
}
pub async fn start(&self, shutdown_tx: mpsc::Sender<()>) {
if let Ok(listener) = TcpListener::bind("127.0.0.1:0").await {
let local_addr = listener.local_addr().unwrap();
let listener = match self.start_tcp_server().await {
Ok(listener) => listener,
Err(err) => {
warn!("Failed to start WS server: {}", err);
let _ = shutdown_tx.send(()).await;
return;
},
};
self.lock_file.lock(local_addr.port().to_string()).unwrap();
info!("WS server listening on port: {}", local_addr.port());
tokio::select! {
_ = watch_vpn_state(self.ctx.vpn_state_rx(), Arc::clone(&self.ctx)) => {
info!("VPN state watch task completed");
}
_ = start_server(listener, self.ctx.clone()) => {
info!("WS server stopped");
}
_ = self.cancel_token.cancelled() => {
info!("WS server cancelled");
}
tokio::select! {
_ = watch_vpn_state(self.ctx.vpn_state_rx(), Arc::clone(&self.ctx)) => {
info!("VPN state watch task completed");
}
_ = start_server(listener, self.ctx.clone()) => {
info!("WS server stopped");
}
_ = self.cancel_token.cancelled() => {
info!("WS server cancelled");
}
}
let _ = shutdown_tx.send(()).await;
}
async fn start_tcp_server(&self) -> anyhow::Result<TcpListener> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let local_addr = listener.local_addr()?;
let port = local_addr.port();
info!("WS server listening on port: {}", port);
self.lock_file.lock(port.to_string())?;
Ok(listener)
}
}
async fn watch_vpn_state(mut vpn_state_rx: watch::Receiver<VpnState>, ctx: Arc<WsServerContext>) {

57
changelog.md Normal file
View File

@@ -0,0 +1,57 @@
# Changelog
## 2.3.1 - 2024-05-21
- Fix the `--sslkey` option not working
## 2.3.0 - 2024-05-20
- Support client certificate authentication (fix [#363](https://github.com/yuezk/GlobalProtect-openconnect/issues/363))
- Support `--disable-ipv6`, `--reconnect-timeout` parameters (related: [#364](https://github.com/yuezk/GlobalProtect-openconnect/issues/364))
- Use default labels if label fields are missing in prelogin response (fix [#357](https://github.com/yuezk/GlobalProtect-openconnect/issues/357))
## 2.2.1 - 2024-05-07
- GUI: Restore the default browser auth implementation (fix [#360](https://github.com/yuezk/GlobalProtect-openconnect/issues/360))
## 2.2.0 - 2024-04-30
- CLI: support authentication with external browser (fix [#298](https://github.com/yuezk/GlobalProtect-openconnect/issues/298))
- GUI: support using file-based storage when the system keyring is not available.
## 2.1.4 - 2024-04-10
- Support MFA authentication (fix [#343](https://github.com/yuezk/GlobalProtect-openconnect/issues/343))
- Improve the Gateway switcher UI
## 2.1.3 - 2024-04-07
- Support CAS authentication (fix [#339](https://github.com/yuezk/GlobalProtect-openconnect/issues/339))
- CLI: Add `--as-gateway` option to connect as gateway directly (fix [#318](https://github.com/yuezk/GlobalProtect-openconnect/issues/318))
- GUI: Support connect the gateway directly (fix [#318](https://github.com/yuezk/GlobalProtect-openconnect/issues/318))
- GUI: Add an option to use symbolic tray icon (fix [#341](https://github.com/yuezk/GlobalProtect-openconnect/issues/341))
## 2.1.2 - 2024-03-29
- Treat portal as gateway when the gateway login is failed (fix #338)
## 2.1.1 - 2024-03-25
- Add the `--hip` option to enable HIP report
- Fix not working in OpenSuse 15.5 (fix #336, #322)
- Treat portal as gateway when the gateway login is failed (fix #338)
- Improve the error message (fix #327)
## 2.1.0 - 2024-02-27
- Update distribution channel for `gpgui` to complaint with the GPL-3 license.
- Add `mtu` option.
- Retry auth if failed to obtain the auth cookie
## 2.0.0 - 2024-02-05
- Refactor using Tauri
- Support HIP report
- Support pass vpn-slice command
- Do not error when the region field is empty
- Update the auth window icon

11
crates/common/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "common"
rust-version.workspace = true
version.workspace = true
authors.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
is_executable.workspace = true

1
crates/common/src/lib.rs Normal file
View File

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

View File

@@ -0,0 +1,54 @@
use std::{io, path::Path};
use is_executable::IsExecutable;
const VPNC_SCRIPT_LOCATIONS: [&str; 6] = [
"/usr/local/share/vpnc-scripts/vpnc-script",
"/usr/local/sbin/vpnc-script",
"/usr/share/vpnc-scripts/vpnc-script",
"/usr/sbin/vpnc-script",
"/etc/vpnc/vpnc-script",
"/etc/openconnect/vpnc-script",
];
const CSD_WRAPPER_LOCATIONS: [&str; 3] = [
#[cfg(target_arch = "x86_64")]
"/usr/lib/x86_64-linux-gnu/openconnect/hipreport.sh",
#[cfg(target_arch = "aarch64")]
"/usr/lib/aarch64-linux-gnu/openconnect/hipreport.sh",
"/usr/lib/openconnect/hipreport.sh",
"/usr/libexec/openconnect/hipreport.sh",
];
fn find_executable(locations: &[&str]) -> Option<String> {
for location in locations.iter() {
let path = Path::new(location);
if path.is_executable() {
return Some(location.to_string());
}
}
None
}
pub fn find_vpnc_script() -> Option<String> {
find_executable(&VPNC_SCRIPT_LOCATIONS)
}
pub fn find_csd_wrapper() -> Option<String> {
find_executable(&CSD_WRAPPER_LOCATIONS)
}
/// If file exists, check if it is executable
pub fn check_executable(file: &str) -> Result<(), io::Error> {
let path = Path::new(file);
if path.exists() && !path.is_executable() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("{} is not executable", file),
));
}
Ok(())
}

View File

@@ -9,6 +9,8 @@ anyhow.workspace = true
base64.workspace = true
log.workspace = true
reqwest.workspace = true
openssl.workspace = true
pem.workspace = true
roxmltree.workspace = true
serde.workspace = true
specta.workspace = true
@@ -27,6 +29,7 @@ dotenvy_macro.workspace = true
uzers.workspace = true
serde_urlencoded.workspace = true
md5.workspace = true
sha256.workspace = true
tauri = { workspace = true, optional = true }
clap = { workspace = true, optional = true }

View File

@@ -1,13 +1,17 @@
use anyhow::bail;
use log::{info, warn};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{error::AuthDataParseError, utils::base64::decode_to_string};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SamlAuthData {
#[serde(alias = "un")]
username: String,
prelogin_cookie: Option<String>,
portal_userauthcookie: Option<String>,
token: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -32,10 +36,11 @@ impl SamlAuthData {
username,
prelogin_cookie,
portal_userauthcookie,
token: None,
}
}
pub fn parse_html(html: &str) -> anyhow::Result<SamlAuthData> {
pub fn from_html(html: &str) -> anyhow::Result<SamlAuthData, AuthDataParseError> {
match parse_xml_tag(html, "saml-auth-status") {
Some(saml_status) if saml_status == "1" => {
let username = parse_xml_tag(html, "saml-username");
@@ -43,21 +48,43 @@ impl SamlAuthData {
let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie");
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
return Ok(SamlAuthData::new(
Ok(SamlAuthData::new(
username.unwrap(),
prelogin_cookie,
portal_userauthcookie,
));
))
} else {
Err(AuthDataParseError::Invalid)
}
}
Some(_) => Err(AuthDataParseError::Invalid),
None => Err(AuthDataParseError::NotFound),
}
}
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 from_gpcallback(data: &str) -> anyhow::Result<SamlAuthData, AuthDataParseError> {
let auth_data = data.trim_start_matches("globalprotectcallback:");
if auth_data.starts_with("cas-as") {
info!("Got CAS auth data from globalprotectcallback");
let auth_data: SamlAuthData = serde_urlencoded::from_str(auth_data).map_err(|e| {
warn!("Failed to parse token auth data: {}", e);
warn!("Auth data: {}", auth_data);
AuthDataParseError::Invalid
})?;
Ok(auth_data)
} else {
info!("Parsing SAML auth data...");
let auth_data = decode_to_string(auth_data).map_err(|e| {
warn!("Failed to decode SAML auth data: {}", e);
AuthDataParseError::Invalid
})?;
let auth_data = Self::from_html(&auth_data)?;
Ok(auth_data)
}
}
@@ -69,6 +96,10 @@ impl SamlAuthData {
self.prelogin_cookie.as_deref()
}
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
pub fn check(
username: &Option<String>,
prelogin_cookie: &Option<String>,
@@ -78,7 +109,16 @@ impl SamlAuthData {
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);
username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid)
let is_valid = username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid);
if !is_valid {
warn!(
"Invalid SAML auth data: username: {:?}, prelogin-cookie: {:?}, portal-userauthcookie: {:?}",
username, prelogin_cookie, portal_userauthcookie
);
}
is_valid
}
}
@@ -88,3 +128,28 @@ pub fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
.and_then(|captures| captures.get(1))
.map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_data_from_gpcallback_cas() {
let auth_data = "globalprotectcallback:cas-as=1&un=xyz@email.com&token=very_long_string";
let auth_data = SamlAuthData::from_gpcallback(auth_data).unwrap();
assert_eq!(auth_data.username(), "xyz@email.com");
assert_eq!(auth_data.token(), Some("very_long_string"));
}
#[test]
fn auth_data_from_gpcallback_non_cas() {
let auth_data = "PGh0bWw+PCEtLSA8c2FtbC1hdXRoLXN0YXR1cz4xPC9zYW1sLWF1dGgtc3RhdHVzPjxwcmVsb2dpbi1jb29raWU+cHJlbG9naW4tY29va2llPC9wcmVsb2dpbi1jb29raWU+PHNhbWwtdXNlcm5hbWU+eHl6QGVtYWlsLmNvbTwvc2FtbC11c2VybmFtZT48c2FtbC1zbG8+bm88L3NhbWwtc2xvPjxzYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+PC9zYW1sLVNlc3Npb25Ob3RPbk9yQWZ0ZXI+IC0tPjwvaHRtbD4=";
let auth_data = SamlAuthData::from_gpcallback(auth_data).unwrap();
assert_eq!(auth_data.username(), "xyz@email.com");
assert_eq!(auth_data.prelogin_cookie(), Some("prelogin-cookie"));
}
}

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::{auth::SamlAuthData, utils::base64::decode_to_string};
use crate::auth::SamlAuthData;
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
@@ -37,16 +37,18 @@ impl From<&CachedCredential> for PasswordCredential {
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreloginCookieCredential {
pub struct PreloginCredential {
username: String,
prelogin_cookie: String,
prelogin_cookie: Option<String>,
token: Option<String>,
}
impl PreloginCookieCredential {
pub fn new(username: &str, prelogin_cookie: &str) -> Self {
impl PreloginCredential {
pub fn new(username: &str, prelogin_cookie: Option<&str>, token: Option<&str>) -> Self {
Self {
username: username.to_string(),
prelogin_cookie: prelogin_cookie.to_string(),
prelogin_cookie: prelogin_cookie.map(|s| s.to_string()),
token: token.map(|s| s.to_string()),
}
}
@@ -54,22 +56,22 @@ impl PreloginCookieCredential {
&self.username
}
pub fn prelogin_cookie(&self) -> &str {
&self.prelogin_cookie
pub fn prelogin_cookie(&self) -> Option<&str> {
self.prelogin_cookie.as_deref()
}
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
}
impl TryFrom<SamlAuthData> for PreloginCookieCredential {
type Error = anyhow::Error;
fn try_from(value: SamlAuthData) -> Result<Self, Self::Error> {
impl From<SamlAuthData> for PreloginCredential {
fn from(value: SamlAuthData) -> Self {
let username = value.username().to_string();
let prelogin_cookie = value
.prelogin_cookie()
.ok_or_else(|| anyhow::anyhow!("Missing prelogin cookie"))?
.to_string();
let prelogin_cookie = value.prelogin_cookie();
let token = value.token();
Ok(Self::new(&username, &prelogin_cookie))
Self::new(&username, prelogin_cookie, token)
}
}
@@ -154,34 +156,30 @@ impl From<PasswordCredential> for CachedCredential {
)
}
}
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Credential {
Password(PasswordCredential),
PreloginCookie(PreloginCookieCredential),
Prelogin(PreloginCredential),
AuthCookie(AuthCookieCredential),
CachedCredential(CachedCredential),
Cached(CachedCredential),
}
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)?;
/// Create a credential from a globalprotectcallback:<base64 encoded string>,
/// or globalprotectcallback:cas-as=1&un=user@xyz.com&token=very_long_string
pub fn from_gpcallback(auth_data: &str) -> anyhow::Result<Self> {
let auth_data = SamlAuthData::from_gpcallback(auth_data)?;
Self::try_from(auth_data)
Ok(Self::from(auth_data))
}
pub fn username(&self) -> &str {
match self {
Credential::Password(cred) => cred.username(),
Credential::PreloginCookie(cred) => cred.username(),
Credential::Prelogin(cred) => cred.username(),
Credential::AuthCookie(cred) => cred.username(),
Credential::CachedCredential(cred) => cred.username(),
Credential::Cached(cred) => cred.username(),
}
}
@@ -189,20 +187,22 @@ impl Credential {
let mut params = HashMap::new();
params.insert("user", self.username());
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),
let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie, token) = match self {
Credential::Password(cred) => (Some(cred.password()), None, None, None, None),
Credential::Prelogin(cred) => (None, cred.prelogin_cookie(), None, None, cred.token()),
Credential::AuthCookie(cred) => (
None,
None,
Some(cred.user_auth_cookie()),
Some(cred.prelogon_user_auth_cookie()),
None,
),
Credential::CachedCredential(cred) => (
Credential::Cached(cred) => (
cred.password(),
None,
Some(cred.auth_cookie.user_auth_cookie()),
Some(cred.auth_cookie.prelogon_user_auth_cookie()),
None,
),
};
@@ -214,17 +214,19 @@ impl Credential {
portal_prelogonuserauthcookie.unwrap_or_default(),
);
if let Some(token) = token {
params.insert("token", token);
}
params
}
}
impl TryFrom<SamlAuthData> for Credential {
type Error = anyhow::Error;
impl From<SamlAuthData> for Credential {
fn from(value: SamlAuthData) -> Self {
let cred = PreloginCredential::from(value);
fn try_from(value: SamlAuthData) -> Result<Self, Self::Error> {
let prelogin_cookie = PreloginCookieCredential::try_from(value)?;
Ok(Self::PreloginCookie(prelogin_cookie))
Self::Prelogin(cred)
}
}
@@ -242,6 +244,6 @@ impl From<&AuthCookieCredential> for Credential {
impl From<&CachedCredential> for Credential {
fn from(value: &CachedCredential) -> Self {
Self::CachedCredential(value.clone())
Self::Cached(value.clone())
}
}

19
crates/gpapi/src/error.rs Normal file
View File

@@ -0,0 +1,19 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PortalError {
#[error("Prelogin error: {0}")]
PreloginError(String),
#[error("Portal config error: {0}")]
ConfigError(String),
#[error("Network error: {0}")]
NetworkError(String),
}
#[derive(Error, Debug)]
pub enum AuthDataParseError {
#[error("No auth data found")]
NotFound,
#[error("Invalid auth data")]
Invalid,
}

View File

@@ -156,11 +156,7 @@ fn build_csd_token(cookie: &str) -> anyhow::Result<String> {
}
pub async fn hip_report(gateway: &str, cookie: &str, csd_wrapper: &str, gp_params: &GpParams) -> anyhow::Result<()> {
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let client = Client::try_from(gp_params)?;
let md5 = build_csd_token(cookie)?;
info!("Submit HIP report md5: {}", md5);

View File

@@ -1,24 +1,27 @@
use anyhow::bail;
use log::info;
use log::{info, warn};
use reqwest::Client;
use roxmltree::Document;
use urlencoding::encode;
use crate::{
credential::Credential,
error::PortalError,
gp_params::GpParams,
utils::{normalize_server, remove_url_scheme},
utils::{normalize_server, parse_gp_response, remove_url_scheme},
};
pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParams) -> anyhow::Result<String> {
pub enum GatewayLogin {
Cookie(String),
Mfa(String, String),
}
pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParams) -> anyhow::Result<GatewayLogin> {
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()?;
let client = Client::try_from(gp_params)?;
let mut params = cred.to_params();
let extra_params = gp_params.to_params();
@@ -28,17 +31,32 @@ pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParam
info!("Gateway login, user_agent: {}", gp_params.user_agent());
let res = client.post(&login_url).form(&params).send().await?;
let status = res.status();
let res = client
.post(&login_url)
.form(&params)
.send()
.await
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
if status.is_client_error() || status.is_server_error() {
bail!("Gateway login error: {}", status)
let res = parse_gp_response(res).await.map_err(|err| {
warn!("{err}");
anyhow::anyhow!("Gateway login error: {}", err.reason)
})?;
// MFA detected
if res.contains("Challenge") {
let Some((message, input_str)) = parse_mfa(&res) else {
bail!("Failed to parse MFA challenge: {res}");
};
return Ok(GatewayLogin::Mfa(message, input_str));
}
let res_xml = res.text().await?;
let doc = Document::parse(&res_xml)?;
let doc = Document::parse(&res)?;
build_gateway_token(&doc, gp_params.computer())
let cookie = build_gateway_token(&doc, gp_params.computer())?;
Ok(GatewayLogin::Cookie(cookie))
}
fn build_gateway_token(doc: &Document, computer: &str) -> anyhow::Result<String> {
@@ -72,3 +90,33 @@ fn read_args<'a>(args: &'a [String], index: usize, key: &'a str) -> anyhow::Resu
.ok_or_else(|| anyhow::anyhow!("Failed to read {key} from args"))
.map(|s| (key, s.as_ref()))
}
fn parse_mfa(res: &str) -> Option<(String, String)> {
let message = res
.lines()
.find(|l| l.contains("respMsg"))
.and_then(|l| l.split('"').nth(1).map(|s| s.to_string()))?;
let input_str = res
.lines()
.find(|l| l.contains("inputStr"))
.and_then(|l| l.split('"').nth(1).map(|s| s.to_string()))?;
Some((message, input_str))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mfa() {
let res = r#"var respStatus = "Challenge";
var respMsg = "MFA message";
thisForm.inputStr.value = "5ef64e83000119ed";"#;
let (message, input_str) = parse_mfa(res).unwrap();
assert_eq!(message, "MFA message");
assert_eq!(input_str, "5ef64e83000119ed");
}
}

View File

@@ -1,9 +1,11 @@
use std::collections::HashMap;
use log::info;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::GP_USER_AGENT;
use crate::{utils::request::create_identity, GP_USER_AGENT};
#[derive(Debug, Serialize, Deserialize, Clone, Type, Default)]
pub enum ClientOs {
@@ -42,7 +44,7 @@ impl ClientOs {
}
}
#[derive(Debug, Serialize, Deserialize, Type, Default)]
#[derive(Debug, Serialize, Deserialize, Type, Default, Clone)]
pub struct GpParams {
is_gateway: bool,
user_agent: String,
@@ -51,7 +53,12 @@ pub struct GpParams {
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
// Used for MFA
input_str: Option<String>,
otp: Option<String>,
}
impl GpParams {
@@ -79,10 +86,6 @@ impl GpParams {
self.ignore_tls_errors
}
pub fn prefer_default_browser(&self) -> bool {
self.prefer_default_browser
}
pub fn client_os(&self) -> &str {
self.client_os.as_str()
}
@@ -95,6 +98,14 @@ impl GpParams {
self.client_version.as_deref()
}
pub fn set_input_str(&mut self, input_str: &str) {
self.input_str = Some(input_str.to_string());
}
pub fn set_otp(&mut self, otp: &str) {
self.otp = Some(otp.to_string());
}
pub(crate) fn to_params(&self) -> HashMap<&str, &str> {
let mut params: HashMap<&str, &str> = HashMap::new();
let client_os = self.client_os.as_str();
@@ -105,11 +116,16 @@ impl GpParams {
params.insert("ok", "Login");
params.insert("direct", "yes");
params.insert("ipv6-support", "yes");
params.insert("inputStr", "");
params.insert("clientVer", "4100");
params.insert("clientos", client_os);
params.insert("computer", &self.computer);
// MFA
params.insert("inputStr", self.input_str.as_deref().unwrap_or_default());
if let Some(otp) = &self.otp {
params.insert("passwd", otp);
}
if let Some(os_version) = &self.os_version {
params.insert("os-version", os_version);
}
@@ -131,20 +147,26 @@ pub struct GpParamsBuilder {
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
}
impl GpParamsBuilder {
pub fn new() -> Self {
let computer = whoami::fallible::hostname().unwrap_or_else(|_| String::from("localhost"));
Self {
is_gateway: false,
user_agent: GP_USER_AGENT.to_string(),
client_os: ClientOs::Linux,
os_version: Default::default(),
client_version: Default::default(),
computer: whoami::hostname(),
computer,
ignore_tls_errors: false,
prefer_default_browser: false,
certificate: Default::default(),
sslkey: Default::default(),
key_password: Default::default(),
}
}
@@ -183,8 +205,18 @@ impl GpParamsBuilder {
self
}
pub fn prefer_default_browser(&mut self, prefer_default_browser: bool) -> &mut Self {
self.prefer_default_browser = prefer_default_browser;
pub fn certificate<T: Into<Option<String>>>(&mut self, certificate: T) -> &mut Self {
self.certificate = certificate.into();
self
}
pub fn sslkey<T: Into<Option<String>>>(&mut self, sslkey: T) -> &mut Self {
self.sslkey = sslkey.into();
self
}
pub fn key_password<T: Into<Option<String>>>(&mut self, password: T) -> &mut Self {
self.key_password = password.into();
self
}
@@ -197,7 +229,11 @@ impl GpParamsBuilder {
client_version: self.client_version.clone(),
computer: self.computer.clone(),
ignore_tls_errors: self.ignore_tls_errors,
prefer_default_browser: self.prefer_default_browser,
certificate: self.certificate.clone(),
sslkey: self.sslkey.clone(),
key_password: self.key_password.clone(),
input_str: Default::default(),
otp: Default::default(),
}
}
}
@@ -207,3 +243,22 @@ impl Default for GpParamsBuilder {
Self::new()
}
}
impl TryFrom<&GpParams> for Client {
type Error = anyhow::Error;
fn try_from(value: &GpParams) -> Result<Self, Self::Error> {
let mut builder = Client::builder()
.danger_accept_invalid_certs(value.ignore_tls_errors)
.user_agent(&value.user_agent);
if let Some(cert) = value.certificate.as_deref() {
info!("Using client certificate authentication...");
let identity = create_identity(cert, value.sslkey.as_deref(), value.key_password.as_deref())?;
builder = builder.identity(identity);
}
let client = builder.build()?;
Ok(client)
}
}

View File

@@ -1,5 +1,6 @@
pub mod auth;
pub mod credential;
pub mod error;
pub mod gateway;
pub mod gp_params;
pub mod portal;
@@ -23,6 +24,8 @@ pub const GP_SERVICE_BINARY: &str = "/usr/bin/gpservice";
#[cfg(not(debug_assertions))]
pub const GP_GUI_BINARY: &str = "/usr/bin/gpgui";
#[cfg(not(debug_assertions))]
pub const GP_GUI_HELPER_BINARY: &str = "/usr/bin/gpgui-helper";
#[cfg(not(debug_assertions))]
pub(crate) const GP_AUTH_BINARY: &str = "/usr/bin/gpauth";
#[cfg(debug_assertions)]
@@ -32,4 +35,6 @@ pub const GP_SERVICE_BINARY: &str = dotenvy_macro::dotenv!("GP_SERVICE_BINARY");
#[cfg(debug_assertions)]
pub const GP_GUI_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_BINARY");
#[cfg(debug_assertions)]
pub const GP_GUI_HELPER_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_HELPER_BINARY");
#[cfg(debug_assertions)]
pub(crate) const GP_AUTH_BINARY: &str = dotenvy_macro::dotenv!("GP_AUTH_BINARY");

View File

@@ -1,5 +1,5 @@
use anyhow::bail;
use log::info;
use log::{info, warn};
use reqwest::{Client, StatusCode};
use roxmltree::Document;
use serde::Serialize;
@@ -7,10 +7,10 @@ use specta::Type;
use crate::{
credential::{AuthCookieCredential, Credential},
error::PortalError,
gateway::{parse_gateways, Gateway},
gp_params::GpParams,
portal::PortalError,
utils::{normalize_server, remove_url_scheme, xml},
utils::{normalize_server, parse_gp_response, remove_url_scheme, xml},
};
#[derive(Debug, Serialize, Type)]
@@ -88,10 +88,7 @@ pub async fn retrieve_config(portal: &str, cred: &Credential, gp_params: &GpPara
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()?;
let client = Client::try_from(gp_params)?;
let mut params = cred.to_params();
let extra_params = gp_params.to_params();
@@ -102,18 +99,25 @@ pub async fn retrieve_config(portal: &str, cred: &Credential, gp_params: &GpPara
info!("Portal config, user_agent: {}", gp_params.user_agent());
let res = client.post(&url).form(&params).send().await?;
let status = res.status();
let res = client
.post(&url)
.form(&params)
.send()
.await
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
if status == StatusCode::NOT_FOUND {
bail!(PortalError::ConfigError("Config endpoint not found".to_string()))
}
let res_xml = parse_gp_response(res).await.or_else(|err| {
if err.status == StatusCode::NOT_FOUND {
bail!(PortalError::ConfigError("Config endpoint not found".to_string()));
}
if status.is_client_error() || status.is_server_error() {
bail!("Portal config error: {}", status)
}
if err.is_status_error() {
warn!("{err}");
bail!("Portal config error: {}", err.reason);
}
let res_xml = res.text().await.map_err(|e| PortalError::ConfigError(e.to_string()))?;
Err(anyhow::anyhow!(PortalError::ConfigError(err.reason)))
})?;
if res_xml.is_empty() {
bail!(PortalError::ConfigError("Empty portal config response".to_string()))

View File

@@ -3,13 +3,3 @@ 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,14 +1,14 @@
use anyhow::bail;
use log::info;
use anyhow::{anyhow, bail};
use log::{info, warn};
use reqwest::{Client, StatusCode};
use roxmltree::Document;
use serde::Serialize;
use specta::Type;
use crate::{
error::PortalError,
gp_params::GpParams,
portal::PortalError,
utils::{base64, normalize_server, xml},
utils::{base64, normalize_server, parse_gp_response, xml},
};
const REQUIRED_PARAMS: [&str; 8] = [
@@ -98,56 +98,61 @@ impl Prelogin {
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 is_gateway = gp_params.is_gateway();
let prelogin_type = if is_gateway { "Gateway" } else { "Portal" };
info!("{} prelogin with user_agent: {}", prelogin_type, user_agent);
let portal = normalize_server(portal)?;
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();
params.insert("tmp", "tmp");
if gp_params.prefer_default_browser() {
params.insert("default-browser", "1");
}
params.insert("default-browser", "1");
params.insert("cas-support", "yes");
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 client = Client::try_from(gp_params)?;
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()
let res = client
.post(&prelogin_url)
.form(&params)
.send()
.await
.map_err(|e| PortalError::PreloginError(e.to_string()))?;
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
let prelogin = parse_res_xml(res_xml, is_gateway).map_err(|e| PortalError::PreloginError(e.to_string()))?;
let res_xml = parse_gp_response(res).await.or_else(|err| {
if err.status == StatusCode::NOT_FOUND {
bail!(PortalError::PreloginError("Prelogin endpoint not found".to_string()))
}
if err.is_status_error() {
warn!("{err}");
bail!("Prelogin error: {}", err.reason)
}
Err(anyhow!(PortalError::PreloginError(err.reason)))
})?;
let prelogin = parse_res_xml(&res_xml, is_gateway).map_err(|err| {
warn!("Parse response error, response: {}", res_xml);
PortalError::PreloginError(err.to_string())
})?;
Ok(prelogin)
}
fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(&res_xml)?;
fn parse_res_xml(res_xml: &str, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(res_xml)?;
let status = xml::get_child_text(&doc, "status")
.ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain status element"))?;
// Check the status of the prelogin response
if status.to_uppercase() != "SUCCESS" {
let msg = xml::get_child_text(&doc, "msg").unwrap_or(String::from("Unknown error"));
bail!("Prelogin failed: {}", msg)
bail!("{}", msg)
}
let region = xml::get_child_text(&doc, "region").unwrap_or_else(|| {
@@ -173,22 +178,24 @@ fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin>
return Ok(Prelogin::Saml(saml_prelogin));
}
let label_username = xml::get_child_text(&doc, "username-label");
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 standard_prelogin = StandardPrelogin {
region,
is_gateway,
auth_message,
label_username: label_username.unwrap(),
label_password: label_password.unwrap(),
};
let label_username = xml::get_child_text(&doc, "username-label").unwrap_or_else(|| {
info!("Username label has no value, using default");
String::from("Username")
});
let label_password = xml::get_child_text(&doc, "password-label").unwrap_or_else(|| {
info!("Password label has no value, using default");
String::from("Password")
});
return Ok(Prelogin::Standard(standard_prelogin));
}
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_password,
};
bail!("Invalid prelogin response");
Ok(Prelogin::Standard(standard_prelogin))
}

View File

@@ -18,6 +18,7 @@ pub struct SamlAuthLauncher<'a> {
fix_openssl: bool,
ignore_tls_errors: bool,
clean: bool,
default_browser: bool,
}
impl<'a> SamlAuthLauncher<'a> {
@@ -33,6 +34,7 @@ impl<'a> SamlAuthLauncher<'a> {
fix_openssl: false,
ignore_tls_errors: false,
clean: false,
default_browser: false,
}
}
@@ -81,8 +83,13 @@ impl<'a> SamlAuthLauncher<'a> {
self
}
pub fn default_browser(mut self, default_browser: bool) -> Self {
self.default_browser = default_browser;
self
}
/// Launch the authenticator binary as the current user or SUDO_USER if available.
pub async fn launch(self) -> anyhow::Result<Credential> {
pub async fn launch(self) -> anyhow::Result<Option<Credential>> {
let mut auth_cmd = Command::new(GP_AUTH_BINARY);
auth_cmd.arg(self.server);
@@ -122,6 +129,10 @@ impl<'a> SamlAuthLauncher<'a> {
auth_cmd.arg("--clean");
}
if self.default_browser {
auth_cmd.arg("--default-browser");
}
let mut non_root_cmd = auth_cmd.into_non_root()?;
let output = non_root_cmd
.kill_on_drop(true)
@@ -130,12 +141,16 @@ impl<'a> SamlAuthLauncher<'a> {
.wait_with_output()
.await?;
if self.default_browser {
return Ok(None);
}
let Ok(auth_result) = serde_json::from_slice::<SamlAuthResult>(&output.stdout) else {
bail!("Failed to parse auth data")
};
match auth_result {
SamlAuthResult::Success(auth_data) => Credential::try_from(auth_data),
SamlAuthResult::Success(auth_data) => Ok(Some(Credential::from(auth_data))),
SamlAuthResult::Failure(msg) => bail!(msg),
}
}

View File

@@ -1,4 +1,7 @@
use std::{env::temp_dir, io::Write};
use std::{env::temp_dir, fs, io::Write, os::unix::fs::PermissionsExt};
use anyhow::bail;
use log::warn;
pub struct BrowserAuthenticator<'a> {
auth_request: &'a str,
@@ -14,8 +17,18 @@ impl BrowserAuthenticator<'_> {
open::that_detached(self.auth_request)?;
} else {
let html_file = temp_dir().join("gpauth.html");
let mut file = std::fs::File::create(&html_file)?;
// Remove the file and error if permission denied
if let Err(err) = fs::remove_file(&html_file) {
if err.kind() != std::io::ErrorKind::NotFound {
warn!("Failed to remove the temporary file: {}", err);
bail!("Please remove the file manually: {:?}", html_file);
}
}
let mut file = fs::File::create(&html_file)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(self.auth_request.as_bytes())?;
open::that_detached(html_file)?;
@@ -24,11 +37,3 @@ impl BrowserAuthenticator<'_> {
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

@@ -12,11 +12,7 @@ pub trait CommandExt {
impl CommandExt for Command {
fn new_pkexec<S: AsRef<OsStr>>(program: S) -> Command {
let mut cmd = Command::new("pkexec");
cmd
.arg("--disable-internal-agent")
.arg("--user")
.arg("root")
.arg(program);
cmd.arg("--user").arg("root").arg(program);
cmd
}

View File

@@ -0,0 +1,68 @@
use std::{collections::HashMap, path::PathBuf, process::Stdio};
use anyhow::bail;
use log::info;
use tokio::{io::AsyncWriteExt, process::Command};
use crate::{process::command_traits::CommandExt, utils, GP_GUI_HELPER_BINARY};
pub struct GuiHelperLauncher<'a> {
program: PathBuf,
envs: Option<&'a HashMap<String, String>>,
api_key: &'a [u8],
gui_version: Option<&'a str>,
}
impl<'a> GuiHelperLauncher<'a> {
pub fn new(api_key: &'a [u8]) -> Self {
Self {
program: GP_GUI_HELPER_BINARY.into(),
envs: None,
api_key,
gui_version: None,
}
}
pub fn envs(mut self, envs: Option<&'a HashMap<String, String>>) -> Self {
self.envs = envs;
self
}
pub fn gui_version(mut self, version: Option<&'a str>) -> Self {
self.gui_version = version;
self
}
pub async fn launch(&self) -> anyhow::Result<()> {
let mut cmd = Command::new(&self.program);
if let Some(envs) = self.envs {
cmd.env_clear();
cmd.envs(envs);
}
cmd.arg("--api-key-on-stdin");
if let Some(gui_version) = self.gui_version {
cmd.arg("--gui-version").arg(gui_version);
}
info!("Launching gpgui-helper");
let mut non_root_cmd = cmd.into_non_root()?;
let mut child = non_root_cmd.kill_on_drop(true).stdin(Stdio::piped()).spawn()?;
let Some(mut stdin) = child.stdin.take() else {
bail!("Failed to open stdin");
};
let api_key = utils::base64::encode(self.api_key);
tokio::spawn(async move {
stdin.write_all(api_key.as_bytes()).await.unwrap();
drop(stdin);
});
let exit_status = child.wait().await?;
info!("gpgui-helper exited with: {}", exit_status);
Ok(())
}
}

View File

@@ -4,30 +4,28 @@ use std::{
process::{ExitStatus, Stdio},
};
use anyhow::bail;
use log::info;
use tokio::{io::AsyncWriteExt, process::Command};
use crate::{utils::base64, GP_GUI_BINARY};
use crate::{process::gui_helper_launcher::GuiHelperLauncher, utils::base64, GP_GUI_BINARY};
use super::command_traits::CommandExt;
pub struct GuiLauncher {
pub struct GuiLauncher<'a> {
version: &'a str,
program: PathBuf,
api_key: Option<Vec<u8>>,
api_key: &'a [u8],
minimized: bool,
envs: Option<HashMap<String, String>>,
}
impl Default for GuiLauncher {
fn default() -> Self {
Self::new()
}
}
impl GuiLauncher {
pub fn new() -> Self {
impl<'a> GuiLauncher<'a> {
pub fn new(version: &'a str, api_key: &'a [u8]) -> Self {
Self {
version,
program: GP_GUI_BINARY.into(),
api_key: None,
api_key,
minimized: false,
envs: None,
}
@@ -38,17 +36,23 @@ impl GuiLauncher {
self
}
pub fn api_key(mut self, api_key: Vec<u8>) -> Self {
self.api_key = Some(api_key);
self
}
pub fn minimized(mut self, minimized: bool) -> Self {
self.minimized = minimized;
self
}
pub async fn launch(&self) -> anyhow::Result<ExitStatus> {
// Check if the program's version
if let Err(err) = self.check_version().await {
info!("Check version failed: {}", err);
// Download the program and replace the current one
self.download_program().await?;
}
self.launch_program().await
}
async fn launch_program(&self) -> anyhow::Result<ExitStatus> {
let mut cmd = Command::new(&self.program);
if let Some(envs) = &self.envs {
@@ -56,33 +60,60 @@ impl GuiLauncher {
cmd.envs(envs);
}
if self.api_key.is_some() {
cmd.arg("--api-key-on-stdin");
}
cmd.arg("--api-key-on-stdin");
if self.minimized {
cmd.arg("--minimized");
}
info!("Launching gpgui");
let mut non_root_cmd = cmd.into_non_root()?;
let mut child = non_root_cmd.kill_on_drop(true).stdin(Stdio::piped()).spawn()?;
let Some(mut stdin) = child.stdin.take() else {
bail!("Failed to open stdin");
};
let mut stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to open stdin"))?;
if let Some(api_key) = &self.api_key {
let api_key = base64::encode(api_key);
tokio::spawn(async move {
stdin.write_all(api_key.as_bytes()).await.unwrap();
drop(stdin);
});
}
let api_key = base64::encode(self.api_key);
tokio::spawn(async move {
stdin.write_all(api_key.as_bytes()).await.unwrap();
drop(stdin);
});
let exit_status = child.wait().await?;
Ok(exit_status)
}
async fn check_version(&self) -> anyhow::Result<()> {
let cmd = Command::new(&self.program).arg("--version").output().await?;
let output = String::from_utf8_lossy(&cmd.stdout);
// Version string: "gpgui 2.0.0 (2024-02-05)"
let Some(version) = output.split_whitespace().nth(1) else {
bail!("Failed to parse version: {}", output);
};
if version != self.version {
bail!("Version mismatch: expected {}, got {}", self.version, version);
}
info!("Version check passed: {}", version);
Ok(())
}
async fn download_program(&self) -> anyhow::Result<()> {
let gui_helper = GuiHelperLauncher::new(self.api_key);
gui_helper
.envs(self.envs.as_ref())
.gui_version(Some(self.version))
.launch()
.await?;
// Check the version again
self.check_version().await?;
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod command_traits;
pub(crate) mod gui_helper_launcher;
pub mod auth_launcher;
#[cfg(feature = "browser-auth")]

View File

@@ -32,9 +32,15 @@ pub struct ConnectArgs {
cookie: String,
vpnc_script: Option<String>,
user_agent: Option<String>,
os: Option<ClientOs>,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
csd_uid: u32,
csd_wrapper: Option<String>,
os: Option<ClientOs>,
reconnect_timeout: u32,
mtu: u32,
disable_ipv6: bool,
}
impl ConnectArgs {
@@ -44,8 +50,14 @@ impl ConnectArgs {
vpnc_script: None,
user_agent: None,
os: None,
certificate: None,
sslkey: None,
key_password: None,
csd_uid: 0,
csd_wrapper: None,
reconnect_timeout: 300,
mtu: 0,
disable_ipv6: false,
}
}
@@ -65,6 +77,18 @@ impl ConnectArgs {
self.os.as_ref().map(|os| os.to_openconnect_os().to_string())
}
pub fn certificate(&self) -> Option<String> {
self.certificate.clone()
}
pub fn sslkey(&self) -> Option<String> {
self.sslkey.clone()
}
pub fn key_password(&self) -> Option<String> {
self.key_password.clone()
}
pub fn csd_uid(&self) -> u32 {
self.csd_uid
}
@@ -72,6 +96,18 @@ impl ConnectArgs {
pub fn csd_wrapper(&self) -> Option<String> {
self.csd_wrapper.clone()
}
pub fn reconnect_timeout(&self) -> u32 {
self.reconnect_timeout
}
pub fn mtu(&self) -> u32 {
self.mtu
}
pub fn disable_ipv6(&self) -> bool {
self.disable_ipv6
}
}
#[derive(Debug, Deserialize, Serialize, Type)]
@@ -113,6 +149,36 @@ impl ConnectRequest {
self
}
pub fn with_certificate<T: Into<Option<String>>>(mut self, certificate: T) -> Self {
self.args.certificate = certificate.into();
self
}
pub fn with_sslkey<T: Into<Option<String>>>(mut self, sslkey: T) -> Self {
self.args.sslkey = sslkey.into();
self
}
pub fn with_key_password<T: Into<Option<String>>>(mut self, key_password: T) -> Self {
self.args.key_password = key_password.into();
self
}
pub fn with_reconnect_timeout(mut self, reconnect_timeout: u32) -> Self {
self.args.reconnect_timeout = reconnect_timeout;
self
}
pub fn with_mtu(mut self, mtu: u32) -> Self {
self.args.mtu = mtu;
self
}
pub fn with_disable_ipv6(mut self, disable_ipv6: bool) -> Self {
self.args.disable_ipv6 = disable_ipv6;
self
}
pub fn gateway(&self) -> &Gateway {
self.info.gateway()
}
@@ -135,3 +201,9 @@ pub enum WsRequest {
Connect(Box<ConnectRequest>),
Disconnect(DisconnectRequest),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UpdateGuiRequest {
pub path: String,
pub checksum: String,
}

View File

@@ -0,0 +1,14 @@
use std::path::Path;
use anyhow::bail;
pub fn verify_checksum(path: &str, expected: &str) -> anyhow::Result<()> {
let file = Path::new(&path);
let checksum = sha256::try_digest(&file)?;
if checksum != expected {
bail!("Checksum mismatch, expected: {}, actual: {}", expected, checksum);
}
Ok(())
}

View File

@@ -1,21 +1,25 @@
use reqwest::Url;
pub(crate) mod xml;
pub mod base64;
pub mod checksum;
pub mod crypto;
pub mod endpoint;
pub mod env_file;
pub mod lock_file;
pub mod openssl;
pub mod redact;
pub mod request;
#[cfg(feature = "tauri")]
pub mod window;
mod shutdown_signal;
use log::warn;
pub use shutdown_signal::shutdown_signal;
use reqwest::{Response, StatusCode, Url};
use thiserror::Error;
/// Normalize the server URL to the format `https://<host>:<port>`
pub fn normalize_server(server: &str) -> anyhow::Result<String> {
let server = if server.starts_with("https://") || server.starts_with("http://") {
@@ -40,3 +44,52 @@ pub fn normalize_server(server: &str) -> anyhow::Result<String> {
pub fn remove_url_scheme(s: &str) -> String {
s.replace("http://", "").replace("https://", "")
}
#[derive(Error, Debug)]
#[error("GP response error: reason={reason}, status={status}, body={body}")]
pub(crate) struct GpError {
pub status: StatusCode,
pub reason: String,
body: String,
}
impl GpError {
pub fn is_status_error(&self) -> bool {
self.status.is_client_error() || self.status.is_server_error()
}
}
pub(crate) async fn parse_gp_response(res: Response) -> anyhow::Result<String, GpError> {
let status = res.status();
if status.is_client_error() || status.is_server_error() {
let (reason, body) = parse_gp_error(res).await;
return Err(GpError { status, reason, body });
}
res.text().await.map_err(|err| {
warn!("Failed to read response: {}", err);
GpError {
status,
reason: "failed to read response".to_string(),
body: "<failed to read response>".to_string(),
}
})
}
async fn parse_gp_error(res: Response) -> (String, String) {
let reason = res
.headers()
.get("x-private-pan-globalprotect")
.map_or_else(|| "<none>", |v| v.to_str().unwrap_or("<invalid header>"))
.to_string();
let res = res.text().await.map_or_else(
|_| "<failed to read response>".to_string(),
|v| if v.is_empty() { "<empty>".to_string() } else { v },
);
(reason, res)
}

View File

@@ -0,0 +1,140 @@
use std::{borrow::Cow, fs};
use anyhow::bail;
use log::warn;
use openssl::pkey::PKey;
use pem::parse_many;
use reqwest::Identity;
#[derive(Debug, thiserror::Error)]
pub enum RequestIdentityError {
#[error("Failed to find the private key")]
NoKey,
#[error("No passphrase provided")]
NoPassphrase(&'static str),
#[error("Failed to decrypt private key")]
DecryptError(&'static str),
}
/// Create an identity object from a certificate and key
/// The file is expected to be the PKCS#8 PEM or PKCS#12 format
/// When using a PKCS#12 file, the key is NOT required, but a passphrase is required
pub fn create_identity(cert: &str, key: Option<&str>, passphrase: Option<&str>) -> anyhow::Result<Identity> {
if cert.ends_with(".p12") || cert.ends_with(".pfx") {
create_identity_from_pkcs12(cert, passphrase)
} else {
create_identity_from_pem(cert, key, passphrase)
}
}
fn create_identity_from_pem(cert: &str, key: Option<&str>, passphrase: Option<&str>) -> anyhow::Result<Identity> {
let cert_pem = fs::read(cert).map_err(|err| anyhow::anyhow!("Failed to read certificate file: {}", err))?;
// Use the certificate as the key if no key is provided
let key_pem_file = match key {
Some(key) => Cow::Owned(fs::read(key).map_err(|err| anyhow::anyhow!("Failed to read key file: {}", err))?),
None => Cow::Borrowed(&cert_pem),
};
// Find the private key in the pem file
let key_pem = parse_many(key_pem_file.as_ref())?
.into_iter()
.find(|pem| pem.tag().ends_with("PRIVATE KEY"))
.ok_or(RequestIdentityError::NoKey)?;
// The key pem could be encrypted, so we need to decrypt it
let decrypted_key_pem = if key_pem.tag().ends_with("ENCRYPTED PRIVATE KEY") {
let passphrase = passphrase.ok_or_else(|| {
warn!("Key is encrypted but no passphrase provided");
RequestIdentityError::NoPassphrase("PEM")
})?;
let pem_content = pem::encode(&key_pem);
let key = PKey::private_key_from_pem_passphrase(pem_content.as_bytes(), passphrase.as_bytes()).map_err(|err| {
warn!("Failed to decrypt key: {}", err);
RequestIdentityError::DecryptError("PEM")
})?;
key.private_key_to_pem_pkcs8()?
} else {
pem::encode(&key_pem).into()
};
let identity = Identity::from_pkcs8_pem(&cert_pem, &decrypted_key_pem)?;
Ok(identity)
}
fn create_identity_from_pkcs12(pkcs12: &str, passphrase: Option<&str>) -> anyhow::Result<Identity> {
let pkcs12 = fs::read(pkcs12)?;
let Some(passphrase) = passphrase else {
bail!(RequestIdentityError::NoPassphrase("PKCS#12"));
};
let identity = Identity::from_pkcs12_der(&pkcs12, passphrase)?;
Ok(identity)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_identity_from_pem_requires_passphrase() {
let cert = "tests/files/badssl.com-client.pem";
let identity = create_identity_from_pem(cert, None, None);
assert!(identity.is_err());
assert!(identity.unwrap_err().to_string().contains("No passphrase provided"));
}
#[test]
fn create_identity_from_pem_with_passphrase() {
let cert = "tests/files/badssl.com-client.pem";
let passphrase = "badssl.com";
let identity = create_identity_from_pem(cert, None, Some(passphrase));
assert!(identity.is_ok());
}
#[test]
fn create_identity_from_pem_unencrypted_key() {
let cert = "tests/files/badssl.com-client-unencrypted.pem";
let identity = create_identity_from_pem(cert, None, None);
println!("{:?}", identity);
assert!(identity.is_ok());
}
#[test]
fn create_identity_from_pem_cert_and_encrypted_key() {
let cert = "tests/files/badssl.com-client.pem";
let key = "tests/files/badssl.com-client.pem";
let passphrase = "badssl.com";
let identity = create_identity_from_pem(cert, Some(key), Some(passphrase));
assert!(identity.is_ok());
}
#[test]
fn create_identity_from_pem_cert_and_encrypted_key_no_passphrase() {
let cert = "tests/files/badssl.com-client.pem";
let key = "tests/files/badssl.com-client.pem";
let identity = create_identity_from_pem(cert, Some(key), None);
assert!(identity.is_err());
assert!(identity.unwrap_err().to_string().contains("No passphrase provided"));
}
#[test]
fn create_identity_from_pem_cert_and_unencrypted_key() {
let cert = "tests/files/badssl.com-client.pem";
let key = "tests/files/badssl.com-client-unencrypted.pem";
let identity = create_identity_from_pem(cert, Some(key), None);
assert!(identity.is_ok());
}
}

View File

@@ -2,17 +2,22 @@ use std::{process::ExitStatus, time::Duration};
use anyhow::bail;
use log::{info, warn};
use tauri::{window::MenuHandle, Window};
use tauri::Window;
use tokio::process::Command;
pub trait WindowExt {
fn raise(&self) -> anyhow::Result<()>;
fn hide_menu(&self);
}
impl WindowExt for Window {
fn raise(&self) -> anyhow::Result<()> {
raise_window(self)
}
fn hide_menu(&self) {
hide_menu(self);
}
}
pub fn raise_window(win: &Window) -> anyhow::Result<()> {
@@ -34,7 +39,8 @@ pub fn raise_window(win: &Window) -> anyhow::Result<()> {
}
// Calling window.show() on Windows will cause the menu to be shown.
hide_menu(win.menu_handle());
// We need to hide it again.
hide_menu(win);
Ok(())
}
@@ -71,7 +77,9 @@ async fn wmctrl_try_raise_window(title: &str) -> anyhow::Result<ExitStatus> {
Ok(exit_status)
}
fn hide_menu(menu_handle: MenuHandle) {
fn hide_menu(win: &Window) {
let menu_handle = win.menu_handle();
tokio::spawn(async move {
loop {
let menu_visible = menu_handle.is_visible().unwrap_or(false);

View File

@@ -0,0 +1,62 @@
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
subject=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Certificate
issuer=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Root Certificate Authority
-----BEGIN CERTIFICATE-----
MIIEnTCCAoWgAwIBAgIJAPfJjkenM2ooMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV
BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp
c2NvMQ8wDQYDVQQKDAZCYWRTU0wxMTAvBgNVBAMMKEJhZFNTTCBDbGllbnQgUm9v
dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjQwNTE3MTc1OTMyWhcNMjYwNTE3
MTc1OTMyWjBvMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQG
A1UEBwwNU2FuIEZyYW5jaXNjbzEPMA0GA1UECgwGQmFkU1NMMSIwIAYDVQQDDBlC
YWRTU0wgQ2xpZW50IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPoen08
utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPca8MR
WAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7AaCi
DeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct4MG8
w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrjQ7i/
s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABoy0wKzAJBgNVHRMEAjAAMBEG
CWCGSAGG+EIBAQQEAwIHgDALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQELBQADggIB
AE6iDW5Lv5I0bJY6TGxJUoB4rcsbbtEP4O4MT14GP7j7I48V09VBG9yjskYze0Ls
Xb9mQpEpPyQLTDJIWu/ic/y5SMnelCjUxmfl37cfNLJajQZxc4FDEUSemrPKpEkB
UzHNkxw9LSzqsyxnQmMIGoN+ZNCFoV7s5pekzPfgZj5+s7a+oiF/AzhOWZzF7vaM
aclX7KCeENQV+q0giDjsGIHI6BevUHYkglocEqff+rIDHjjLxHLPooflV50M+ifc
4uJdHgG8hwKxd1uf3LImUsquiBrW5CO6KCgwLrtQNe11pQHpY0urZxK/tnAj7QtD
v/O1ryd/3+b0Gx14TyulMtcaLHsE94ppwjcxpYGNcyH+M39OMihuR2aqmkrqcZd/
VWop1cNwZgPtCNVvfivRpX52NLI5I0eMfs6jeTMr719hdAby3akoiNLN3YNKrdrp
pyRz/sUFGO8AHHECXA15KTeMBNfZnO32ZAZ4jHyyDBO1A5f9iDbErhXfIpeRCrCO
gM9MLuO4YEMG1Skp+qaw7SIaG+oi2t4lbVRr3LOv0Hfkjjb7bVjfWSwLBPH/gv0E
ZL6G0p7PjeoCh4obS3Y1yxfNlPR6RQwWl1wve+Nkmf5sDCmgr3P0512ZuvqkbKkB
/syiAWDsYzFuq2Ntv2ljTYPEPwXEIQcpsagDRL6WzoLR
-----END CERTIFICATE-----
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
Key Attributes: <No Attributes>
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDHN18R6x5Oz+u6
SOXLoxIscz5GHR6cDcCLgyPax2XfXHdJs+h6fTy61WGM+aXEhR2SIwbj5997s34m
0MsbvkJrFmn0LHK1fuTLCihEEmxGdCGZA9xrwxFYAkEjP7D8v7cAWRMipYF/JP7V
U7xNUo+QSkZ0sOi9k6bNkABKL3+yP6PqAzsBoKIN5lN/YRLrppsDmk6nrRDo4R3C
D+8JQl9quEoOmL22Pc/qpOjL1jgOIFSE5y3gwbzDlfCYoAL5V+by1vu0yJShTTK8
oo5wvphcFfEHaQ9w5jFg2htdq99UER3BKuNDuL+zejqGQZCWb0Xsk8S5WBuX8l3B
rrg5giqNAgMBAAECggEAVRB/t9b9igmeTlzyQpHPIMvUu3uTpm742JmWpcSe61FA
XmhDzInNdLnIfbnb3p44kj4Coy5PbzKlm01sbNxA4BkiBPE1yen1J/2eU/LJ6QuN
jRjo9drFfR75UWPQ3xu9uJhQY2rocLILXmvy69FlG+ebThh8SPbTMtNaTFMb47An
pk2FrW9+rzPswbklOxls/SDt78usRvfAjslm73IdBTOrbceF+GmYs3/SXz1gu05p
LxY2rhC8piBlqnD/QbXBahZbhjb9SkDFn2typMFZKkJIIKDJaOI2E9tIlZ97/0nZ
txqchMty8IuU9YYAfLXCmj2IEfnvLtL7thLfKLuWAQKBgQDyXBpEgKFzfy2a1AI0
+1qL/u5UN14l7S6/wmyDTgVMXwoxhwPRXWD5PutQ8D6tMfC/y4AYt3OXg1blCvLD
XysNj5SK+dpmQR0SyeWjd9zwxJAXvx0McJefCYd86YGcGhJsuX5bkHIeQlEc6df7
yoqr1480VQx/+Fk1i6Zr0EIUFQKBgQDSbalUOfXZh2EVRQEgf3VoPlxAiwGGQcVT
i+pbjMG3pOwmkVyJZusGtN5HN4Oi7n1oiyfMYGsszKQ5j4TDBGS70pNUzhTv3Vn8
0Vsfz0arJRqJxviiv4FfDmsYXwObNKwOjR+LEn1NUPkOYOLdz1lDuWOu11LE90Dy
Q6hg8WwCmQKBgQDTy5lI9AAjpqh7/XpQQrhGT2qHPjuQeU25Vnbt6GjI7OVDkvHL
LQdpyYprGQgs4s+5TGWNNARYC/cMAh1Ujv5Yw3jUWrR5V73IhZeg20bBQYWKuwDv
thVKblFw377cZAxl51R9QCX6O4oW8mRFLiMxORd0bD6YNrf/CyNMZJraYQKBgAE7
o0JbFJWxtV/qh5cpKAb0VpYKOngO6pkSuMzQhlINJVUUhPZJJBdl9+dy69KIkzOJ
nTIVXotkp5GuxZhe7jgrg7F7g6PkKCLTFzWYgVF/ZihoggxyEs/7xaTe6aZ/KILt
UMH/2bwaPVtYNfwWuu8qpurfWBzPVhIVU2c+AuQBAoGAXMbw10vyiznlhyMFw5kx
SzlBMqJBLJkzQBtpvXuT0lqqxTSNC3N4WxgVOLCHa6HqXiB0790YL8/RWunsXTk2
c7ugThP6iMPNVAycWkIF4vvHTwZ9RCSmEQabRaqGGLz/bhLL3fi3lPGCR+iW2Dxq
GTH3fhaM/pZZGdIC75x/69Y=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,64 @@
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
subject=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Certificate
issuer=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Root Certificate Authority
-----BEGIN CERTIFICATE-----
MIIEnTCCAoWgAwIBAgIJAPfJjkenM2ooMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV
BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp
c2NvMQ8wDQYDVQQKDAZCYWRTU0wxMTAvBgNVBAMMKEJhZFNTTCBDbGllbnQgUm9v
dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjQwNTE3MTc1OTMyWhcNMjYwNTE3
MTc1OTMyWjBvMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQG
A1UEBwwNU2FuIEZyYW5jaXNjbzEPMA0GA1UECgwGQmFkU1NMMSIwIAYDVQQDDBlC
YWRTU0wgQ2xpZW50IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPoen08
utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPca8MR
WAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7AaCi
DeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct4MG8
w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrjQ7i/
s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABoy0wKzAJBgNVHRMEAjAAMBEG
CWCGSAGG+EIBAQQEAwIHgDALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQELBQADggIB
AE6iDW5Lv5I0bJY6TGxJUoB4rcsbbtEP4O4MT14GP7j7I48V09VBG9yjskYze0Ls
Xb9mQpEpPyQLTDJIWu/ic/y5SMnelCjUxmfl37cfNLJajQZxc4FDEUSemrPKpEkB
UzHNkxw9LSzqsyxnQmMIGoN+ZNCFoV7s5pekzPfgZj5+s7a+oiF/AzhOWZzF7vaM
aclX7KCeENQV+q0giDjsGIHI6BevUHYkglocEqff+rIDHjjLxHLPooflV50M+ifc
4uJdHgG8hwKxd1uf3LImUsquiBrW5CO6KCgwLrtQNe11pQHpY0urZxK/tnAj7QtD
v/O1ryd/3+b0Gx14TyulMtcaLHsE94ppwjcxpYGNcyH+M39OMihuR2aqmkrqcZd/
VWop1cNwZgPtCNVvfivRpX52NLI5I0eMfs6jeTMr719hdAby3akoiNLN3YNKrdrp
pyRz/sUFGO8AHHECXA15KTeMBNfZnO32ZAZ4jHyyDBO1A5f9iDbErhXfIpeRCrCO
gM9MLuO4YEMG1Skp+qaw7SIaG+oi2t4lbVRr3LOv0Hfkjjb7bVjfWSwLBPH/gv0E
ZL6G0p7PjeoCh4obS3Y1yxfNlPR6RQwWl1wve+Nkmf5sDCmgr3P0512ZuvqkbKkB
/syiAWDsYzFuq2Ntv2ljTYPEPwXEIQcpsagDRL6WzoLR
-----END CERTIFICATE-----
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
Key Attributes: <No Attributes>
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIET6L0Ht/lYgCAggA
MBQGCCqGSIb3DQMHBAi1Xo+JdQ6XvwSCBMgX20Fk3/GzptJ0zjl7ZqX2G3J4LIkM
E5qJ4yv2WUkCCOWqz5DjlSrRz4kdCYHqnM1/qyrLa1UWWJlNQ9lBHTE+yp0vtAC/
ajQfKt3RFyGxblp6nEKJI7kvhmQHDbITilmVEpcZPbci3gi7asQI3bRSLaHwGtbH
DY+8hJ8lZQMRjYGDyGb99qEdYnMMRMW+b44lIRASe6W3EUfrvJlp+OUqRA7hJzn2
yha9Zo8KWo9fA9UZDFFKNlXakg76+1HymB+uqvZl14xHHfwhlKPaqzmCb8MUtt7e
YJDB9I3y8aHKExXPbRk04bbY5G9o6WdWslDUY4axOZuhUXyn0h6cTZn//qmsjcBH
499+j55vW6W7vkMfurt/pmLIBWC9kDWPZVLizbfXxWiWvRmQvKPfzO5TU8oObYyJ
qUVjb3Vpa/WPrF5APUVd/DDofurgzdOkmDGomONPvSHxahHSyEZsxpnl52GD6uU/
i3oa5qLE9uA1QjyX6wyN9SU5wE2FZKTJwwRJwW4+s4T/2eJjhuJez5q1xhSCes4A
A2pufAAY/ctQSmCCKCTW+EkrXtcezx66fkgPpNK/m6bz5KGJkA4QXjl8A05PDAFE
Z68VOX/T0IGfXc2BbPgP0u+WpCvvO2cW/pU4sjcwOMxFuT1Bn3TwmDLTZ+zba1rE
zFRMMCz/8SKq3I+VkzQ66ureEz0RLwk07JVzE9AJUEm+zCFUdoIaz09OMGVqtf4a
V+UgupH0QlffmRNJKQtXPuj6Wjfa43GLaCnN/cpXXq8+2o81dLTsCbEsYu+8DRjC
B0iyjzdqgjBBYurIEwEc4iGtPt4Y+4rgAJcpEUgwvWii37xyutOC9V7ansvd6zg3
WXiX5Ktj/qS0EzM33WtZfx7jygJIf1MvxrJU+D+HgGii1mHaZ6bHxMX3QGpRsEvh
IzBx16XvoHcXARZJG91bC+K1sJ6e05L1PevS7gj4heJTEhtmvABUrn9O1n5fZWPj
Q81zRDgplMO7r8aBW/pE+sj4VSTMg0Xu0nlqqvQoWxr9YFcJm0+I9fHQPxewnRus
sBZoiTqnWqbTr+uRATRUAp+hU03S4jGZwbzH4ylL2hr/TshGVJk/olBsULAfIiHa
dA5H258IEwAoFO6zgI9AvqmTFo3Mnpqb/AS/HuDmmS/3Ud1EF8hFsMLPcV0JdSTY
Dl4xgZ6j6jOUlTN5Yt6To2Zg3Q9Bm6qytFaffEP66Jl5aWhksI31Fz/ihzn5wfx9
xh91U8+kGVNrpYHlo5y3FR/ywSXynLkJffCbfUciEaTDv9i0JppoIVXyFqcMofHe
GUsWTCozAW3O8MwpLaJxcNcfRq0DWziIdiDgbF2tPoCqnNxXtLYSPpdt3jNDcPcx
U0Z6ep6FnAXiujtQRSRSP3Ssq23098BxDSM9+eashFOmSbSClAEEn/THRxTp/gMh
zmD8kpX1zN1Cm/lerTGjrGjnkXcQ7LY76/+C1uT+tQbw5LjmCfFEYTFtnFyYFlF1
GiXFokh9SdLaCzW4vmZok85Fe+7VZ7BAchBTfTIMKlXKmeouf3YVYJ8glPsinrjb
cB2pKv3tVrdQwo3moYDwSsDgkd7BNKKHDVdY2O6NgX4/Fyd6pZt7ZAphyC1giEqg
pPo=
-----END ENCRYPTED PRIVATE KEY-----

View File

@@ -6,8 +6,8 @@ license.workspace = true
links = "openconnect"
[dependencies]
common = { path = "../common" }
log.workspace = true
is_executable.workspace = true
[build-dependencies]
cc = "1"

View File

@@ -14,10 +14,16 @@ pub(crate) struct ConnectOptions {
pub script: *const c_char,
pub os: *const c_char,
pub certificate: *const c_char,
pub sslkey: *const c_char,
pub key_password: *const c_char,
pub servercert: *const c_char,
pub csd_uid: u32,
pub csd_wrapper: *const c_char,
pub reconnect_timeout: u32,
pub mtu: u32,
pub disable_ipv6: u32,
}
#[link(name = "vpn")]

View File

@@ -16,7 +16,7 @@ static vpn_connected_callback on_vpn_connected;
/* Validate the peer certificate */
static int validate_peer_cert(__attribute__((unused)) void *_vpninfo, const char *reason)
{
INFO("Validating peer cert: %s", reason);
INFO("Accepting the server certificate though %s", reason);
return 0;
}
@@ -28,12 +28,9 @@ static void print_progress(__attribute__((unused)) void *_vpninfo, int level, co
char *message = format_message(format, args);
va_end(args);
if (message == NULL)
{
if (message == NULL) {
ERROR("Failed to format log message");
}
else
{
} else {
LOG(level, message);
free(message);
}
@@ -63,11 +60,13 @@ int vpn_connect(const vpn_options *options, vpn_connected_callback callback)
INFO("OS: %s", options->os);
INFO("CSD_USER: %d", options->csd_uid);
INFO("CSD_WRAPPER: %s", options->csd_wrapper);
INFO("RECONNECT_TIMEOUT: %d", options->reconnect_timeout);
INFO("MTU: %d", options->mtu);
INFO("DISABLE_IPV6: %d", options->disable_ipv6);
vpninfo = openconnect_vpninfo_new(options->user_agent, validate_peer_cert, NULL, NULL, print_progress, NULL);
if (!vpninfo)
{
if (!vpninfo) {
ERROR("openconnect_vpninfo_new failed");
return 1;
}
@@ -82,54 +81,55 @@ int vpn_connect(const vpn_options *options, vpn_connected_callback callback)
openconnect_set_reported_os(vpninfo, options->os);
}
if (options->certificate)
{
if (options->certificate) {
INFO("Setting client certificate: %s", options->certificate);
openconnect_set_client_cert(vpninfo, options->certificate, NULL);
openconnect_set_client_cert(vpninfo, options->certificate, options->sslkey);
}
if (options->servercert) {
INFO("Setting server certificate: %s", options->servercert);
openconnect_set_system_trust(vpninfo, 0);
if (options->key_password) {
openconnect_set_key_password(vpninfo, options->key_password);
}
if (options->csd_wrapper) {
openconnect_setup_csd(vpninfo, options->csd_uid, 1, options->csd_wrapper);
}
if (options->mtu > 0) {
int mtu = options->mtu < 576 ? 576 : options->mtu;
openconnect_set_reqmtu(vpninfo, mtu);
}
if (options->disable_ipv6) {
openconnect_disable_ipv6(vpninfo);
}
g_cmd_pipe_fd = openconnect_setup_cmd_pipe(vpninfo);
if (g_cmd_pipe_fd < 0)
{
if (g_cmd_pipe_fd < 0) {
ERROR("openconnect_setup_cmd_pipe failed");
return 1;
}
if (!uname(&utsbuf))
{
if (!uname(&utsbuf)) {
openconnect_set_localname(vpninfo, utsbuf.nodename);
}
// Essential step
if (openconnect_make_cstp_connection(vpninfo) != 0)
{
if (openconnect_make_cstp_connection(vpninfo) != 0) {
ERROR("openconnect_make_cstp_connection failed");
return 1;
}
if (openconnect_setup_dtls(vpninfo, 60) != 0)
{
if (openconnect_setup_dtls(vpninfo, 60) != 0) {
openconnect_disable_dtls(vpninfo);
}
// Essential step
openconnect_set_setup_tun_handler(vpninfo, setup_tun_handler);
while (1)
{
int ret = openconnect_mainloop(vpninfo, 300, 10);
while (1) {
int ret = openconnect_mainloop(vpninfo, options->reconnect_timeout, 10);
if (ret)
{
if (ret) {
INFO("openconnect_mainloop returned %d, exiting", ret);
openconnect_vpninfo_free(vpninfo);
return ret;
@@ -146,8 +146,7 @@ void vpn_disconnect()
INFO("Stopping VPN connection: %d", g_cmd_pipe_fd);
if (write(g_cmd_pipe_fd, &cmd, 1) < 0)
{
if (write(g_cmd_pipe_fd, &cmd, 1) < 0) {
ERROR("Failed to write to command pipe, VPN connection may not be stopped");
}
}

View File

@@ -15,10 +15,16 @@ typedef struct vpn_options
const char *script;
const char *os;
const char *certificate;
const char *sslkey;
const char *key_password;
const char *servercert;
const uid_t csd_uid;
const char *csd_wrapper;
const int reconnect_timeout;
const int mtu;
const int disable_ipv6;
} vpn_options;
int vpn_connect(const vpn_options *options, vpn_connected_callback callback);
@@ -33,7 +39,7 @@ static char *format_message(const char *format, va_list args)
int len = vsnprintf(NULL, 0, format, args_copy);
va_end(args_copy);
char *buffer = malloc(len + 1);
char *buffer = (char*)malloc(len + 1);
if (buffer == NULL)
{
return NULL;

View File

@@ -1,5 +1,4 @@
mod ffi;
mod vpn;
mod vpnc_script;
pub use vpn::*;

View File

@@ -1,11 +1,13 @@
use std::{
ffi::{c_char, CString},
fmt,
sync::{Arc, RwLock},
};
use common::vpn_utils::{check_executable, find_vpnc_script};
use log::info;
use crate::{ffi, vpnc_script::find_default_vpnc_script};
use crate::ffi;
type OnConnectedCallback = Arc<RwLock<Option<Box<dyn FnOnce() + 'static + Send + Sync>>>>;
@@ -16,11 +18,17 @@ pub struct Vpn {
script: CString,
os: CString,
certificate: Option<CString>,
sslkey: Option<CString>,
key_password: Option<CString>,
servercert: Option<CString>,
csd_uid: u32,
csd_wrapper: Option<CString>,
reconnect_timeout: u32,
mtu: u32,
disable_ipv6: bool,
callback: OnConnectedCallback,
}
@@ -57,11 +65,18 @@ impl Vpn {
user_agent: self.user_agent.as_ptr(),
script: self.script.as_ptr(),
os: self.os.as_ptr(),
certificate: Self::option_to_ptr(&self.certificate),
sslkey: Self::option_to_ptr(&self.sslkey),
key_password: Self::option_to_ptr(&self.key_password),
servercert: Self::option_to_ptr(&self.servercert),
csd_uid: self.csd_uid,
csd_wrapper: Self::option_to_ptr(&self.csd_wrapper),
reconnect_timeout: self.reconnect_timeout,
mtu: self.mtu,
disable_ipv6: self.disable_ipv6 as u32,
}
}
@@ -73,15 +88,43 @@ impl Vpn {
}
}
#[derive(Debug)]
pub struct VpnError {
message: String,
}
impl VpnError {
fn new(message: String) -> Self {
Self { message }
}
}
impl fmt::Display for VpnError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for VpnError {}
pub struct VpnBuilder {
server: String,
cookie: String,
user_agent: Option<String>,
script: Option<String>,
user_agent: Option<String>,
os: Option<String>,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
csd_uid: u32,
csd_wrapper: Option<String>,
reconnect_timeout: u32,
mtu: u32,
disable_ipv6: bool,
}
impl VpnBuilder {
@@ -89,17 +132,22 @@ impl VpnBuilder {
Self {
server: server.to_string(),
cookie: cookie.to_string(),
user_agent: None,
script: None,
user_agent: None,
os: None,
certificate: None,
sslkey: None,
key_password: None,
csd_uid: 0,
csd_wrapper: None,
}
}
pub fn user_agent<T: Into<Option<String>>>(mut self, user_agent: T) -> Self {
self.user_agent = user_agent.into();
self
reconnect_timeout: 300,
mtu: 0,
disable_ipv6: false,
}
}
pub fn script<T: Into<Option<String>>>(mut self, script: T) -> Self {
@@ -107,11 +155,31 @@ impl VpnBuilder {
self
}
pub fn user_agent<T: Into<Option<String>>>(mut self, user_agent: T) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn os<T: Into<Option<String>>>(mut self, os: T) -> Self {
self.os = os.into();
self
}
pub fn certificate<T: Into<Option<String>>>(mut self, certificate: T) -> Self {
self.certificate = certificate.into();
self
}
pub fn sslkey<T: Into<Option<String>>>(mut self, sslkey: T) -> Self {
self.sslkey = sslkey.into();
self
}
pub fn key_password<T: Into<Option<String>>>(mut self, key_password: T) -> Self {
self.key_password = key_password.into();
self
}
pub fn csd_uid(mut self, csd_uid: u32) -> Self {
self.csd_uid = csd_uid;
self
@@ -122,25 +190,58 @@ impl VpnBuilder {
self
}
pub fn build(self) -> Vpn {
pub fn reconnect_timeout(mut self, reconnect_timeout: u32) -> Self {
self.reconnect_timeout = reconnect_timeout;
self
}
pub fn mtu(mut self, mtu: u32) -> Self {
self.mtu = mtu;
self
}
pub fn disable_ipv6(mut self, disable_ipv6: bool) -> Self {
self.disable_ipv6 = disable_ipv6;
self
}
pub fn build(self) -> Result<Vpn, VpnError> {
let script = match self.script {
Some(script) => {
check_executable(&script).map_err(|e| VpnError::new(e.to_string()))?;
script
}
None => find_vpnc_script().ok_or_else(|| VpnError::new(String::from("Failed to find vpnc-script")))?,
};
if let Some(csd_wrapper) = &self.csd_wrapper {
check_executable(csd_wrapper).map_err(|e| VpnError::new(e.to_string()))?;
}
let user_agent = self.user_agent.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 {
Ok(Vpn {
server: Self::to_cstring(&self.server),
cookie: Self::to_cstring(&self.cookie),
user_agent: Self::to_cstring(&user_agent),
script: Self::to_cstring(&script),
os: Self::to_cstring(&os),
certificate: None,
certificate: self.certificate.as_deref().map(Self::to_cstring),
sslkey: self.sslkey.as_deref().map(Self::to_cstring),
key_password: self.key_password.as_deref().map(Self::to_cstring),
servercert: None,
csd_uid: self.csd_uid,
csd_wrapper: self.csd_wrapper.as_deref().map(Self::to_cstring),
reconnect_timeout: self.reconnect_timeout,
mtu: self.mtu,
disable_ipv6: self.disable_ipv6,
callback: Default::default(),
}
})
}
fn to_cstring(value: &str) -> CString {

View File

@@ -1,23 +0,0 @@
use is_executable::IsExecutable;
use std::path::Path;
const VPNC_SCRIPT_LOCATIONS: [&str; 5] = [
"/usr/local/share/vpnc-scripts/vpnc-script",
"/usr/local/sbin/vpnc-script",
"/usr/share/vpnc-scripts/vpnc-script",
"/usr/sbin/vpnc-script",
"/etc/vpnc/vpnc-script",
];
pub(crate) fn find_default_vpnc_script() -> Option<String> {
for location in VPNC_SCRIPT_LOCATIONS.iter() {
let path = Path::new(location);
if path.is_executable() {
return Some(location.to_string());
}
}
log::warn!("vpnc-script not found");
None
}

View File

@@ -0,0 +1,34 @@
install:
@echo "===> Installing..."
install -Dm755 artifacts/usr/bin/gpclient $(DESTDIR)/usr/bin/gpclient
install -Dm755 artifacts/usr/bin/gpservice $(DESTDIR)/usr/bin/gpservice
install -Dm755 artifacts/usr/bin/gpauth $(DESTDIR)/usr/bin/gpauth
install -Dm755 artifacts/usr/bin/gpgui-helper $(DESTDIR)/usr/bin/gpgui-helper
if [ -f artifacts/usr/bin/gpgui ]; then \
install -Dm755 artifacts/usr/bin/gpgui $(DESTDIR)/usr/bin/gpgui; \
fi
install -Dm644 artifacts/usr/share/applications/gpgui.desktop $(DESTDIR)/usr/share/applications/gpgui.desktop
install -Dm644 artifacts/usr/share/icons/hicolor/scalable/apps/gpgui.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg
install -Dm644 artifacts/usr/share/icons/hicolor/32x32/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/32x32/apps/gpgui.png
install -Dm644 artifacts/usr/share/icons/hicolor/128x128/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/128x128/apps/gpgui.png
install -Dm644 artifacts/usr/share/icons/hicolor/256x256@2/apps/gpgui.png $(DESTDIR)/usr/share/icons/hicolor/256x256@2/apps/gpgui.png
install -Dm644 artifacts/usr/share/polkit-1/actions/com.yuezk.gpgui.policy $(DESTDIR)/usr/share/polkit-1/actions/com.yuezk.gpgui.policy
uninstall:
@echo "===> Uninstalling from $(DESTDIR)..."
rm -f $(DESTDIR)/usr/bin/gpclient
rm -f $(DESTDIR)/usr/bin/gpservice
rm -f $(DESTDIR)/usr/bin/gpauth
rm -f $(DESTDIR)/usr/bin/gpgui-helper
rm -f $(DESTDIR)/usr/bin/gpgui
rm -f $(DESTDIR)/usr/share/applications/gpgui.desktop
rm -f $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg
rm -f $(DESTDIR)/usr/share/icons/hicolor/32x32/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/icons/hicolor/128x128/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/icons/hicolor/256x256@2/apps/gpgui.png
rm -f $(DESTDIR)/usr/share/polkit-1/actions/com.yuezk.gpgui.policy

23
packaging/deb/control.in Normal file
View File

@@ -0,0 +1,23 @@
Source: globalprotect-openconnect
Section: net
Priority: optional
Maintainer: Kevin Yue <k3vinyue@gmail.com>
Standards-Version: 4.1.4
Build-Depends: debhelper (>= 9),
pkg-config,
jq (>= 1),
make (>= 4),
libxml2,
libsecret-1-0,
libayatana-appindicator3-1,
gnome-keyring,
libwebkit2gtk-4.0-dev,
libopenconnect-dev (>= 8.20),@RUST@
Homepage: https://github.com/yuezk/GlobalProtect-openconnect
Package: globalprotect-openconnect
Architecture: any
Multi-Arch: foreign
Depends: ${misc:Depends}, ${shlibs:Depends}, openconnect (>=8.20), libxml2, libsecret-1-0, libayatana-appindicator3-1, gnome-keyring
Description: A GUI for GlobalProtect VPN
A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authentication method.

14
packaging/deb/postrm Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
case "$1" in
purge|remove|upgrade)
# Remove the gpgui binary downloaded at runtime
rm -f /usr/bin/gpgui
;;
*)
;;
esac
exit 0

7
packaging/deb/rules.in Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/make -f
export OFFLINE = @OFFLINE@
export BUILD_FE = 0
%:
dh $@ --no-parallel

Some files were not shown because too many files have changed in this diff Show More