Compare commits

..

52 Commits

Author SHA1 Message Date
Kevin Yue
148468eee3 Release 2.3.2 2024-06-17 20:54:04 +08:00
Kevin Yue
79083e5664 feat: Remove dotenvy_macro crate from dependencies 2024-06-15 10:58:36 +08:00
Kevin Yue
c52d2bc0b6 fix: Decode CAS callback before parsing
Related: #372
2024-06-13 14:34:58 +00:00
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
46 changed files with 1495 additions and 403 deletions

View File

@@ -9,8 +9,10 @@ on:
branches: branches:
- main - main
- dev - dev
- hotfix/*
- feature/*
- release/*
tags: tags:
- latest
- v*.*.* - v*.*.*
jobs: jobs:
# Include arm64 if ref is a tag # Include arm64 if ref is a tag
@@ -23,9 +25,9 @@ jobs:
id: set-matrix id: set-matrix
run: | run: |
if [[ "${{ github.ref }}" == "refs/tags/"* ]]; then if [[ "${{ github.ref }}" == "refs/tags/"* ]]; then
echo "matrix=[\"ubuntu-latest\", \"arm64\"]" >> $GITHUB_OUTPUT echo 'matrix=[{"runner": "ubuntu-latest", "arch": "amd64"}, {"runner": "arm64", "arch": "arm64"}]' >> $GITHUB_OUTPUT
else else
echo "matrix=[\"ubuntu-latest\"]" >> $GITHUB_OUTPUT echo 'matrix=[{"runner": "ubuntu-latest", "arch": "amd64"}]' >> $GITHUB_OUTPUT
fi fi
tarball: tarball:
@@ -42,10 +44,15 @@ jobs:
with: with:
token: ${{ secrets.GH_PAT }} token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect repository: yuezk/GlobalProtect-openconnect
ref: ${{ github.ref }}
path: source/gp path: source/gp
- name: Create tarball - name: Create tarball
run: | run: |
cd source/gp cd source/gp
# Generate the SNAPSHOT file for non-tagged commits
if [[ "${{ github.ref }}" != "refs/tags/"* ]]; then
touch SNAPSHOT
fi
make tarball make tarball
- name: Upload tarball - name: Upload tarball
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -61,21 +68,42 @@ jobs:
- tarball - tarball
strategy: strategy:
matrix: matrix:
os: ${{fromJson(needs.setup-matrix.outputs.matrix)}} # Only build gp on amd64, as the arm64 package will be built in release.yaml
runs-on: ${{ matrix.os }} 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: steps:
- name: Prepare workspace - name: Prepare workspace
run: rm -rf build-gp && mkdir build-gp run: |
rm -rf build-gp-${{ matrix.package }}
mkdir -p build-gp-${{ matrix.package }}
- name: Download tarball - name: Download tarball
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: artifact-source name: artifact-source
path: build-gp path: build-gp-${{ matrix.package }}
- name: Docker Login - name: Docker Login
run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build gp in Docker - name: Build ${{ matrix.package }} package in Docker
run: | run: |
docker run --rm -v $(pwd)/build-gp:/gp yuezk/gpdev:gp-builder 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/*
build-gpgui: build-gpgui:
needs: needs:
@@ -83,7 +111,8 @@ jobs:
strategy: strategy:
matrix: matrix:
os: ${{fromJson(needs.setup-matrix.outputs.matrix)}} os: ${{fromJson(needs.setup-matrix.outputs.matrix)}}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os.runner }}
name: build-gpgui (${{ matrix.os.arch }})
steps: steps:
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
@@ -95,12 +124,14 @@ jobs:
with: with:
token: ${{ secrets.GH_PAT }} token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect repository: yuezk/GlobalProtect-openconnect
ref: ${{ github.ref }}
path: gpgui-source/gp path: gpgui-source/gp
- name: Checkout gpgui - name: Checkout gpgui@${{ github.ref_name }}
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
token: ${{ secrets.GH_PAT }} token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui repository: yuezk/gpgui
ref: ${{ github.ref_name }}
path: gpgui-source/gpgui path: gpgui-source/gpgui
- name: Tarball - name: Tarball
run: | run: |
@@ -120,31 +151,40 @@ jobs:
- name: Upload gpgui - name: Upload gpgui
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: artifact-gpgui-${{ matrix.os }} name: artifact-gpgui-${{ matrix.os.arch }}
if-no-files-found: error if-no-files-found: error
path: | path: |
gpgui-source/*.bin.tar.xz gpgui-source/*.bin.tar.xz
gpgui-source/*.bin.tar.xz.sha256 gpgui-source/*.bin.tar.xz.sha256
gh-release: gh-release:
if: startsWith(github.ref, 'refs/tags/') if: ${{ github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- tarball
- build-gp - build-gp
- build-gpgui - build-gpgui
steps: steps:
- name: Prepare workspace - name: Prepare workspace
run: rm -rf gh-release && mkdir gh-release run: rm -rf gh-release && mkdir gh-release
- name: Checkout GlobalProtect-openconnect
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
ref: ${{ github.ref }}
path: gh-release/gp
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
path: gh-release path: gh-release/gp/.build/artifacts
- name: Create GH release - name: Create GH release
uses: softprops/action-gh-release@v1 env:
with: GH_TOKEN: ${{ secrets.GH_PAT }}
token: ${{ secrets.GH_PAT }} RELEASE_TAG: ${{ github.ref == 'refs/heads/dev' && 'snapshot' || github.ref_name }}
prerelease: ${{ contains(github.ref, 'latest') }} run: |
fail_on_unmatched_files: true cd gh-release/gp/scripts && ./gh-release.sh "$RELEASE_TAG"
files: |
gh-release/artifact-*/*

View File

@@ -109,11 +109,16 @@ jobs:
run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin run: echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build ${{ matrix.package }} package in Docker - name: Build ${{ matrix.package }} package in Docker
run: | run: |
docker run --rm -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} -e INCLUDE_GUI=1 yuezk/gpdev:${{ matrix.package }}-builder 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 - name: Install ${{ matrix.package }} package in Docker
run: | run: |
docker run --rm -v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} yuezk/gpdev:${{ matrix.package }}-builder \ docker run --rm \
-v $(pwd)/build-${{ matrix.package }}:/${{ matrix.package }} \
yuezk/gpdev:${{ matrix.package }}-builder \
bash install.sh bash install.sh
- name: Upload ${{ matrix.package }} package - name: Upload ${{ matrix.package }} package
@@ -140,7 +145,7 @@ jobs:
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
token: ${{ secrets.GH_PAT }} token: ${{ secrets.GH_PAT }}
prerelease: ${{ contains(github.ref, 'latest') }} prerelease: ${{ contains(github.ref, 'snapshot') }}
fail_on_unmatched_files: true fail_on_unmatched_files: true
tag_name: ${{ inputs.tag }} tag_name: ${{ inputs.tag }}
files: | files: |

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@
.cargo .cargo
.build .build
SNAPSHOT

View File

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

94
Cargo.lock generated
View File

@@ -252,6 +252,12 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -564,7 +570,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"is_executable", "is_executable",
] ]
@@ -892,24 +898,6 @@ dependencies = [
"litrs", "litrs",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dotenvy_macro"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb0235d912a8c749f4e0c9f18ca253b4c28cfefc1d2518096016d6e3230b6424"
dependencies = [
"dotenvy",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "1.0.9" version = "1.0.9"
@@ -1430,16 +1418,17 @@ dependencies = [
[[package]] [[package]]
name = "gpapi" name = "gpapi"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.21.5", "base64 0.21.5",
"chacha20poly1305", "chacha20poly1305",
"clap", "clap",
"dotenvy_macro",
"log", "log",
"md5", "md5",
"open", "open",
"openssl",
"pem",
"redact-engine", "redact-engine",
"regex", "regex",
"reqwest", "reqwest",
@@ -1462,13 +1451,14 @@ dependencies = [
[[package]] [[package]]
name = "gpauth" name = "gpauth"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"compile-time", "compile-time",
"env_logger", "env_logger",
"gpapi", "gpapi",
"html-escape",
"log", "log",
"regex", "regex",
"serde_json", "serde_json",
@@ -1482,7 +1472,7 @@ dependencies = [
[[package]] [[package]]
name = "gpclient" name = "gpclient"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -1504,7 +1494,7 @@ dependencies = [
[[package]] [[package]]
name = "gpgui-helper" name = "gpgui-helper"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -1522,7 +1512,7 @@ dependencies = [
[[package]] [[package]]
name = "gpservice" name = "gpservice"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1598,9 +1588,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.24" version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@@ -1617,9 +1607,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.2" version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943" checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@@ -1673,6 +1663,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.26.0" version = "0.26.0"
@@ -1777,7 +1776,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.3.24", "h2 0.3.26",
"http 0.2.11", "http 0.2.11",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
@@ -1800,7 +1799,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"h2 0.4.2", "h2 0.4.4",
"http 1.0.0", "http 1.0.0",
"http-body 1.0.0", "http-body 1.0.0",
"httparse", "httparse",
@@ -2527,7 +2526,7 @@ dependencies = [
[[package]] [[package]]
name = "openconnect" name = "openconnect"
version = "2.1.1" version = "2.3.2"
dependencies = [ dependencies = [
"cc", "cc",
"common", "common",
@@ -2660,6 +2659,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -3157,7 +3166,7 @@ dependencies = [
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.3.24", "h2 0.3.26",
"http 0.2.11", "http 0.2.11",
"http-body 0.4.6", "http-body 0.4.6",
"hyper 0.14.28", "hyper 0.14.28",
@@ -4484,6 +4493,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.1" version = "0.2.1"
@@ -4590,6 +4605,12 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.89" version = "0.2.89"
@@ -4766,11 +4787,12 @@ dependencies = [
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.4.1" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9"
dependencies = [ dependencies = [
"wasm-bindgen", "redox_syscall",
"wasite",
"web-sys", "web-sys",
] ]

View File

@@ -5,7 +5,7 @@ members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth", "apps/g
[workspace.package] [workspace.package]
rust-version = "1.70" rust-version = "1.70"
version = "2.1.1" version = "2.3.2"
authors = ["Kevin Yue <k3vinyue@gmail.com>"] authors = ["Kevin Yue <k3vinyue@gmail.com>"]
homepage = "https://github.com/yuezk/GlobalProtect-openconnect" homepage = "https://github.com/yuezk/GlobalProtect-openconnect"
edition = "2021" edition = "2021"
@@ -22,6 +22,8 @@ is_executable = "1.0"
log = "0.4" log = "0.4"
regex = "1" regex = "1"
reqwest = { version = "0.11", features = ["native-tls-vendored", "json"] } reqwest = { version = "0.11", features = ["native-tls-vendored", "json"] }
openssl = "0.10"
pem = "3"
roxmltree = "0.18" roxmltree = "0.18"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
@@ -39,7 +41,6 @@ uzers = "0.11"
whoami = "1" whoami = "1"
thiserror = "1" thiserror = "1"
redact-engine = "0.1" redact-engine = "0.1"
dotenvy_macro = "0.15"
compile-time = "0.2" compile-time = "0.2"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
md5="0.7" md5="0.7"

View File

@@ -15,6 +15,13 @@ PUBLISH ?= 0
export DEBEMAIL = k3vinyue@gmail.com export DEBEMAIL = k3vinyue@gmail.com
export DEBFULLNAME = Kevin Yue 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 CARGO_BUILD_ARGS = --release
@@ -61,7 +68,8 @@ download-gui:
if [ $(INCLUDE_GUI) -eq 1 ]; then \ if [ $(INCLUDE_GUI) -eq 1 ]; then \
echo "Downloading GlobalProtect GUI..."; \ echo "Downloading GlobalProtect GUI..."; \
mkdir -p .build/gpgui; \ mkdir -p .build/gpgui; \
curl -sSL https://github.com/yuezk/GlobalProtect-openconnect/releases/download/v$(VERSION)/gpgui_$(VERSION)_$(shell uname -m).bin.tar.xz -o .build/gpgui/gpgui_$(VERSION)_x$(shell uname -m).bin.tar.xz; \ 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; \ tar -xJf .build/gpgui/*.tar.xz -C .build/gpgui; \
else \ else \
echo "Skipping GlobalProtect GUI download (INCLUDE_GUI=0)"; \ echo "Skipping GlobalProtect GUI download (INCLUDE_GUI=0)"; \
@@ -195,7 +203,7 @@ init-rpm: clean-rpm
sed -i "s/@VERSION@/$(VERSION)/g" .build/rpm/globalprotect-openconnect.spec 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/@REVISION@/$(REVISION)/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/rpm/globalprotect-openconnect.spec sed -i "s/@OFFLINE@/$(OFFLINE)/g" .build/rpm/globalprotect-openconnect.spec
sed -i "s/@DATE@/$(shell date "+%a %b %d %Y")/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/@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 sed -i "s/@DATE@/$(shell LC_ALL=en.US date -u "+%a %b %e %T %Z %Y")/g" .build/rpm/globalprotect-openconnect.changes

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 both SSO and non-SSO authentication
- [x] Support the FIDO2 authentication (e.g., YubiKey) - [x] Support the FIDO2 authentication (e.g., YubiKey)
- [x] Support authentication using default browser - [x] Support authentication using default browser
- [x] Support client certificate authentication
- [x] Support multiple portals - [x] Support multiple portals
- [x] Support gateway selection - [x] Support gateway selection
- [x] Support connect gateway directly - [x] Support connect gateway directly
@@ -43,6 +44,12 @@ Options:
See 'gpclient help <command>' for more information on a specific command. 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 ### 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. 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.
@@ -55,7 +62,7 @@ The GUI version is also available after you installed it. You can launch it from
### Debian/Ubuntu based distributions ### 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 apt-get install gir1.2-gtk-3.0 gir1.2-webkit2-4.0
@@ -68,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`. > 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 ```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 ### Arch Linux / Manjaro
@@ -120,6 +144,30 @@ Download the latest RPM package from [releases](https://github.com/yuezk/GlobalP
```bash ```bash
sudo rpm -i globalprotect-openconnect-*.rpm 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 ### Other distributions
@@ -151,6 +199,8 @@ You can also build the client from source, steps are as follows:
1. How to deal with error `Secure Storage not ready` 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)). 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:` 2. How to deal with error `(gpauth:18869): Gtk-WARNING **: 10:33:37.566: cannot open display:`

View File

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

View File

@@ -7,6 +7,7 @@ use std::{
use anyhow::bail; use anyhow::bail;
use gpapi::{ use gpapi::{
auth::SamlAuthData, auth::SamlAuthData,
error::AuthDataParseError,
gp_params::GpParams, gp_params::GpParams,
portal::{prelogin, Prelogin}, portal::{prelogin, Prelogin},
utils::{redact::redact_uri, window::WindowExt}, utils::{redact::redact_uri, window::WindowExt},
@@ -184,6 +185,10 @@ impl<'a> AuthWindow<'a> {
} }
info!("Loaded uri: {}", redact_uri(&uri)); info!("Loaded uri: {}", redact_uri(&uri));
if uri.starts_with("globalprotectcallback:") {
return;
}
read_auth_data(&main_resource, auth_result_tx_clone.clone()); 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| { wv.connect_load_failed(move |_wv, _event, uri, err| {
let redacted_uri = redact_uri(uri); 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 // NOTE: Don't send error here, since load_changed event will be triggered after this
// send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid)); // send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
// true to stop other handlers from being invoked for the event. false to propagate the event further. // true 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) fn read_auth_data_from_body<F>(main_resource: &WebResource, callback: F)
where where
F: FnOnce(AuthResult) + Send + 'static, F: FnOnce(Result<SamlAuthData, AuthDataParseError>) + Send + 'static,
{ {
main_resource.data(Cancellable::NONE, |data| match data { main_resource.data(Cancellable::NONE, |data| match data {
Ok(data) => { Ok(data) => {
@@ -348,53 +355,41 @@ where
} }
Err(err) => { Err(err) => {
info!("Failed to read response body: {}", 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") { if html.contains("Temporarily Unavailable") {
info!("Found 'Temporarily Unavailable' in HTML, auth failed"); info!("Found 'Temporarily Unavailable' in HTML, auth failed");
return Err(AuthDataError::Invalid); return Err(AuthDataParseError::Invalid);
} }
match parse_xml_tag(html, "saml-auth-status") { SamlAuthData::from_html(html).or_else(|err| {
Some(saml_status) if saml_status == "1" => { if let Some(gpcallback) = extract_gpcallback(html) {
let username = parse_xml_tag(html, "saml-username"); info!("Found gpcallback from html...");
let prelogin_cookie = parse_xml_tag(html, "prelogin-cookie"); SamlAuthData::from_gpcallback(&gpcallback)
let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie"); } else {
Err(err)
}
})
}
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) { fn extract_gpcallback(html: &str) -> Option<String> {
return Ok(SamlAuthData::new( let re = Regex::new(r#"globalprotectcallback:[^"]+"#).unwrap();
username.unwrap(), re.captures(html)
prelogin_cookie, .and_then(|captures| captures.get(0))
portal_userauthcookie, .map(|m| html_escape::decode_html_entities(m.as_str()).to_string())
));
}
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 read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSender<AuthResult>) { 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"); info!("No response found in main resource");
send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid)); send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
return; return;
} };
let response = main_resource.response().unwrap();
info!("Trying to read auth data from response headers..."); info!("Trying to read auth data from response headers...");
match read_auth_data_from_headers(&response) { match read_auth_data_from_headers(&response) {
@@ -407,22 +402,27 @@ fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSe
read_auth_data_from_body(main_resource, move |auth_result| { 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 // 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 // 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); send_auth_result(&auth_result_tx, auth_result);
}); });
} }
Err(AuthDataError::NotFound) => { Err(AuthDataError::NotFound) => {
info!("No auth data found in headers, trying to read from body..."); info!("No auth data found in headers, trying to read from body...");
let url = main_resource.uri().unwrap_or("".into());
let is_acs_endpoint = url.contains("/SAML20/SP/ACS"); 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| { 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 // 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| { let auth_result = auth_result.map_err(|err| {
if matches!(err, AuthDataError::NotFound) && is_acs_endpoint { info!("Failed to read auth data from body: {}", err);
AuthDataError::Invalid
if !is_acs_endpoint && matches!(err, AuthDataParseError::NotFound) {
AuthDataError::NotFound
} else { } else {
err AuthDataError::Invalid
} }
}); });
@@ -437,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<()> { pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()> {
let (tx, rx) = oneshot::channel::<Result<(), String>>(); let (tx, rx) = oneshot::channel::<Result<(), String>>();
@@ -489,3 +482,42 @@ pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()>
rx.await?.map_err(|err| anyhow::anyhow!(err)) 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}, auth::{SamlAuthData, SamlAuthResult},
clap::args::Os, clap::args::Os,
gp_params::{ClientOs, GpParams}, gp_params::{ClientOs, GpParams},
process::browser_authenticator::BrowserAuthenticator,
utils::{normalize_server, openssl}, utils::{normalize_server, openssl},
GP_USER_AGENT, GP_USER_AGENT,
}; };
@@ -37,6 +38,8 @@ struct Cli {
ignore_tls_errors: bool, ignore_tls_errors: bool,
#[arg(long)] #[arg(long)]
clean: bool, clean: bool,
#[arg(long)]
default_browser: bool,
} }
impl Cli { impl Cli {
@@ -56,6 +59,15 @@ impl Cli {
None => portal_prelogin(&self.server, &gp_params).await?, 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); self.saml_request.replace(saml_request);
let app = create_app(self.clone())?; let app = create_app(self.clone())?;

View File

@@ -19,7 +19,7 @@ pub(crate) struct SharedArgs {
#[derive(Subcommand)] #[derive(Subcommand)]
enum CliCommand { enum CliCommand {
#[command(about = "Connect to a portal server")] #[command(about = "Connect to a portal server")]
Connect(ConnectArgs), Connect(Box<ConnectArgs>),
#[command(about = "Disconnect from the server")] #[command(about = "Disconnect from the server")]
Disconnect, Disconnect,
#[command(about = "Launch the GUI")] #[command(about = "Launch the GUI")]

View File

@@ -1,4 +1,4 @@
use std::{fs, sync::Arc}; use std::{cell::RefCell, fs, sync::Arc};
use clap::Args; use clap::Args;
use common::vpn_utils::find_csd_wrapper; use common::vpn_utils::find_csd_wrapper;
@@ -6,21 +6,22 @@ use gpapi::{
clap::args::Os, clap::args::Os,
credential::{Credential, PasswordCredential}, credential::{Credential, PasswordCredential},
error::PortalError, error::PortalError,
gateway::gateway_login, gateway::{gateway_login, GatewayLogin},
gp_params::{ClientOs, GpParams}, gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, Prelogin}, portal::{prelogin, retrieve_config, Prelogin},
process::{ process::{
auth_launcher::SamlAuthLauncher, auth_launcher::SamlAuthLauncher,
users::{get_non_root_user, get_user_by_name}, users::{get_non_root_user, get_user_by_name},
}, },
utils::shutdown_signal, utils::{request::RequestIdentityError, shutdown_signal},
GP_USER_AGENT, GP_USER_AGENT,
}; };
use inquire::{Password, PasswordDisplayMode, Select, Text}; use inquire::{Password, PasswordDisplayMode, Select, Text};
use log::info; use log::info;
use openconnect::Vpn; 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)] #[derive(Args)]
pub(crate) struct ConnectArgs { pub(crate) struct ConnectArgs {
@@ -32,6 +33,8 @@ pub(crate) struct ConnectArgs {
user: Option<String>, user: Option<String>,
#[arg(long, short, help = "The VPNC script to use")] #[arg(long, short, help = "The VPNC script to use")]
script: Option<String>, script: Option<String>,
#[arg(long, help = "Connect the server as a gateway, instead of a portal")]
as_gateway: bool,
#[arg( #[arg(
long, long,
@@ -39,14 +42,29 @@ pub(crate) struct ConnectArgs {
)] )]
hip: bool, 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")] #[arg(long, help = "Same as the '--csd-user' option in the openconnect command")]
csd_user: Option<String>, csd_user: Option<String>,
#[arg(long, help = "Same as the '--csd-wrapper' option in the openconnect command")] #[arg(long, help = "Same as the '--csd-wrapper' option in the openconnect command")]
csd_wrapper: Option<String>, 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)")] #[arg(short, long, help = "Request MTU from server (legacy servers only)")]
mtu: Option<u32>, 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")] #[arg(long, default_value = GP_USER_AGENT, help = "The user agent to use")]
user_agent: String, user_agent: String,
@@ -58,6 +76,8 @@ pub(crate) struct ConnectArgs {
hidpi: bool, hidpi: bool,
#[arg(long, help = "Do not reuse the remembered authentication cookie")] #[arg(long, help = "Do not reuse the remembered authentication cookie")]
clean: bool, clean: bool,
#[arg(long, help = "Use the default browser to authenticate")]
default_browser: bool,
} }
impl ConnectArgs { impl ConnectArgs {
@@ -77,11 +97,16 @@ impl ConnectArgs {
pub(crate) struct ConnectHandler<'a> { pub(crate) struct ConnectHandler<'a> {
args: &'a ConnectArgs, args: &'a ConnectArgs,
shared_args: &'a SharedArgs, shared_args: &'a SharedArgs,
latest_key_password: RefCell<Option<String>>,
} }
impl<'a> ConnectHandler<'a> { impl<'a> ConnectHandler<'a> {
pub(crate) fn new(args: &'a ConnectArgs, shared_args: &'a SharedArgs) -> Self { 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 { fn build_gp_params(&self) -> GpParams {
@@ -90,11 +115,52 @@ impl<'a> ConnectHandler<'a> {
.client_os(ClientOs::from(&self.args.os)) .client_os(ClientOs::from(&self.args.os))
.os_version(self.args.os_version()) .os_version(self.args.os_version())
.ignore_tls_errors(self.shared_args.ignore_tls_errors) .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() .build()
} }
pub(crate) async fn handle(&self) -> anyhow::Result<()> { 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 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 { let Err(err) = self.connect_portal_with_prelogin(server).await else {
return Ok(()); return Ok(());
@@ -103,10 +169,15 @@ impl<'a> ConnectHandler<'a> {
info!("Failed to connect portal with prelogin: {}", err); info!("Failed to connect portal with prelogin: {}", err);
if err.root_cause().downcast_ref::<PortalError>().is_some() { if err.root_cause().downcast_ref::<PortalError>().is_some() {
info!("Trying the gateway authentication workflow..."); 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<()> { async fn connect_portal_with_prelogin(&self, portal: &str) -> anyhow::Result<()> {
@@ -141,7 +212,7 @@ impl<'a> ConnectHandler<'a> {
let gateway = selected_gateway.server(); let gateway = selected_gateway.server();
let cred = portal_config.auth_cookie().into(); 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, Ok(cookie) => cookie,
Err(err) => { Err(err) => {
info!("Gateway login failed: {}", err); info!("Gateway login failed: {}", err);
@@ -153,7 +224,7 @@ impl<'a> ConnectHandler<'a> {
} }
async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> { async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> {
info!("Treat the portal as the gateway, connecting..."); info!("Performing the gateway authentication...");
let mut gp_params = self.build_gp_params(); let mut gp_params = self.build_gp_params();
gp_params.set_is_gateway(true); gp_params.set_is_gateway(true);
@@ -161,11 +232,28 @@ impl<'a> ConnectHandler<'a> {
let prelogin = prelogin(gateway, &gp_params).await?; let prelogin = prelogin(gateway, &gp_params).await?;
let cred = self.obtain_credential(&prelogin, gateway).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 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<()> { async fn connect_gateway(&self, gateway: &str, cookie: &str) -> anyhow::Result<()> {
let mtu = self.args.mtu.unwrap_or(0); let mtu = self.args.mtu.unwrap_or(0);
let csd_uid = get_csd_uid(&self.args.csd_user)?; let csd_uid = get_csd_uid(&self.args.csd_user)?;
@@ -180,9 +268,14 @@ impl<'a> ConnectHandler<'a> {
let vpn = Vpn::builder(gateway, cookie) let vpn = Vpn::builder(gateway, cookie)
.script(self.args.script.clone()) .script(self.args.script.clone())
.user_agent(self.args.user_agent.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_uid(csd_uid)
.csd_wrapper(csd_wrapper) .csd_wrapper(csd_wrapper)
.reconnect_timeout(self.args.reconnect_timeout)
.mtu(mtu) .mtu(mtu)
.disable_ipv6(self.args.disable_ipv6)
.build()?; .build()?;
let vpn = Arc::new(vpn); let vpn = Arc::new(vpn);
@@ -210,7 +303,9 @@ impl<'a> ConnectHandler<'a> {
match prelogin { match prelogin {
Prelogin::Saml(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) .gateway(is_gateway)
.saml_request(prelogin.saml_request()) .saml_request(prelogin.saml_request())
.user_agent(&self.args.user_agent) .user_agent(&self.args.user_agent)
@@ -220,8 +315,21 @@ impl<'a> ConnectHandler<'a> {
.fix_openssl(self.shared_args.fix_openssl) .fix_openssl(self.shared_args.fix_openssl)
.ignore_tls_errors(self.shared_args.ignore_tls_errors) .ignore_tls_errors(self.shared_args.ignore_tls_errors)
.clean(self.args.clean) .clean(self.args.clean)
.default_browser(use_default_browser)
.launch() .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) => { Prelogin::Standard(prelogin) => {
let prefix = if is_gateway { "Gateway" } else { "Portal" }; let prefix = if is_gateway { "Gateway" } else { "Portal" };
@@ -244,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() { fn write_pid_file() {
let pid = std::process::id(); 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 clap::Args;
use directories::ProjectDirs; use directories::ProjectDirs;
@@ -7,6 +7,9 @@ use gpapi::{
utils::{endpoint::http_endpoint, env_file, shutdown_signal}, utils::{endpoint::http_endpoint, env_file, shutdown_signal},
}; };
use log::info; use log::info;
use tokio::io::AsyncWriteExt;
use crate::GP_CLIENT_PORT_FILE;
#[derive(Args)] #[derive(Args)]
pub(crate) struct LaunchGuiArgs { pub(crate) struct LaunchGuiArgs {
@@ -78,11 +81,21 @@ impl<'a> LaunchGuiHandler<'a> {
} }
async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> { 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?; let service_endpoint = http_endpoint().await?;
reqwest::Client::default() reqwest::Client::default()
.post(format!("{}/auth-data", service_endpoint)) .post(format!("{}/auth-data", service_endpoint))
.json(&auth_data) .body(auth_data.to_string())
.send() .send()
.await? .await?
.error_for_status()?; .error_for_status()?;
@@ -90,6 +103,15 @@ async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
Ok(()) 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<()> { async fn try_active_gui() -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?; let service_endpoint = http_endpoint().await?;

View File

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

View File

@@ -31,6 +31,6 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"prettier": "3.1.0", "prettier": "3.1.0",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vite": "^4.5.2" "vite": "^4.5.3"
} }
} }

View File

@@ -48,7 +48,7 @@ devDependencies:
version: 6.12.0(eslint@8.54.0)(typescript@5.0.2) version: 6.12.0(eslint@8.54.0)(typescript@5.0.2)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3(vite@4.5.2) version: 4.0.3(vite@4.5.3)
eslint: eslint:
specifier: ^8.54.0 specifier: ^8.54.0
version: 8.54.0 version: 8.54.0
@@ -68,8 +68,8 @@ devDependencies:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
vite: vite:
specifier: ^4.5.2 specifier: ^4.5.3
version: 4.5.2(@types/node@20.8.10) version: 4.5.3(@types/node@20.8.10)
packages: packages:
@@ -1229,7 +1229,7 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: true dev: true
/@vitejs/plugin-react@4.0.3(vite@4.5.2): /@vitejs/plugin-react@4.0.3(vite@4.5.3):
resolution: {integrity: sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg==} resolution: {integrity: sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies: peerDependencies:
@@ -1239,7 +1239,7 @@ packages:
'@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.2) '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.2)
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2) '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2)
react-refresh: 0.14.0 react-refresh: 0.14.0
vite: 4.5.2(@types/node@20.8.10) vite: 4.5.3(@types/node@20.8.10)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
@@ -2979,8 +2979,8 @@ packages:
punycode: 2.3.1 punycode: 2.3.1
dev: true dev: true
/vite@4.5.2(@types/node@20.8.10): /vite@4.5.3(@types/node@20.8.10):
resolution: {integrity: sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==} resolution: {integrity: sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:

View File

@@ -9,6 +9,12 @@ use tauri::{Manager, Window};
use crate::downloader::{ChecksumFetcher, FileDownloader}; use crate::downloader::{ChecksumFetcher, FileDownloader};
#[cfg(not(debug_assertions))]
const SNAPSHOT: &str = match option_env!("SNAPSHOT") {
Some(val) => val,
None => "false"
};
pub struct ProgressNotifier { pub struct ProgressNotifier {
win: Window, win: Window,
} }
@@ -81,9 +87,13 @@ impl GuiUpdater {
info!("Update GUI, version: {}", self.version); info!("Update GUI, version: {}", self.version);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let release_tag = "latest"; let release_tag = "snapshot";
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let release_tag = format!("v{}", self.version); let release_tag = if SNAPSHOT == "true" {
String::from("snapshot")
} else {
format!("v{}", self.version)
};
#[cfg(target_arch = "x86_64")] #[cfg(target_arch = "x86_64")]
let arch = "x86_64"; let arch = "x86_64";
@@ -91,8 +101,8 @@ impl GuiUpdater {
let arch = "aarch64"; let arch = "aarch64";
let file_url = format!( let file_url = format!(
"https://github.com/yuezk/GlobalProtect-openconnect/releases/download/{}/gpgui_{}_{}.bin.tar.xz", "https://github.com/yuezk/GlobalProtect-openconnect/releases/download/{}/gpgui_{}.bin.tar.xz",
release_tag, self.version, arch release_tag, arch
); );
let checksum_url = format!("{}.sha256", file_url); let checksum_url = format!("{}.sha256", file_url);

View File

@@ -38,10 +38,15 @@ impl VpnTaskContext {
let vpn = match Vpn::builder(req.gateway().server(), args.cookie()) let vpn = match Vpn::builder(req.gateway().server(), args.cookie())
.script(args.vpnc_script()) .script(args.vpnc_script())
.user_agent(args.user_agent()) .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_uid(args.csd_uid())
.csd_wrapper(args.csd_wrapper()) .csd_wrapper(args.csd_wrapper())
.reconnect_timeout(args.reconnect_timeout())
.mtu(args.mtu()) .mtu(args.mtu())
.os(args.openconnect_os()) .disable_ipv6(args.disable_ipv6())
.build() .build()
{ {
Ok(vpn) => vpn, Ok(vpn) => vpn,

View File

@@ -118,28 +118,41 @@ impl WsServer {
} }
pub async fn start(&self, shutdown_tx: mpsc::Sender<()>) { pub async fn start(&self, shutdown_tx: mpsc::Sender<()>) {
if let Ok(listener) = TcpListener::bind("127.0.0.1:0").await { let listener = match self.start_tcp_server().await {
let local_addr = listener.local_addr().unwrap(); 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(); tokio::select! {
_ = watch_vpn_state(self.ctx.vpn_state_rx(), Arc::clone(&self.ctx)) => {
info!("WS server listening on port: {}", local_addr.port()); info!("VPN state watch task completed");
}
tokio::select! { _ = start_server(listener, self.ctx.clone()) => {
_ = watch_vpn_state(self.ctx.vpn_state_rx(), Arc::clone(&self.ctx)) => { info!("WS server stopped");
info!("VPN state watch task completed"); }
} _ = self.cancel_token.cancelled() => {
_ = start_server(listener, self.ctx.clone()) => { info!("WS server cancelled");
info!("WS server stopped");
}
_ = self.cancel_token.cancelled() => {
info!("WS server cancelled");
}
} }
} }
let _ = shutdown_tx.send(()).await; 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>) { async fn watch_vpn_state(mut vpn_state_rx: watch::Receiver<VpnState>, ctx: Arc<WsServerContext>) {

View File

@@ -1,5 +1,47 @@
# Changelog # Changelog
## 2.3.2 - 2024-06-17
- Fix the CAS callback parsing issue (fix [#372](https://github.com/yuezk/GlobalProtect-openconnect/issues/372))
- CLI: fix the `/tmp/gpauth.html` deletion issue (fix [#366](https://github.com/yuezk/GlobalProtect-openconnect/issues/366))
- GUI: fix the license not working after reboot (fix [#376](https://github.com/yuezk/GlobalProtect-openconnect/issues/376))
- GUI: add the license activation management link
## 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 ## 2.1.1 - 2024-03-25
- Add the `--hip` option to enable HIP report - Add the `--hip` option to enable HIP report

View File

@@ -1,7 +1,6 @@
use is_executable::IsExecutable; use std::{io, path::Path};
use std::path::Path;
pub use is_executable::is_executable; use is_executable::IsExecutable;
const VPNC_SCRIPT_LOCATIONS: [&str; 6] = [ const VPNC_SCRIPT_LOCATIONS: [&str; 6] = [
"/usr/local/share/vpnc-scripts/vpnc-script", "/usr/local/share/vpnc-scripts/vpnc-script",
@@ -39,3 +38,17 @@ pub fn find_vpnc_script() -> Option<String> {
pub fn find_csd_wrapper() -> Option<String> { pub fn find_csd_wrapper() -> Option<String> {
find_executable(&CSD_WRAPPER_LOCATIONS) 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 base64.workspace = true
log.workspace = true log.workspace = true
reqwest.workspace = true reqwest.workspace = true
openssl.workspace = true
pem.workspace = true
roxmltree.workspace = true roxmltree.workspace = true
serde.workspace = true serde.workspace = true
specta.workspace = true specta.workspace = true
@@ -23,7 +25,6 @@ chacha20poly1305 = { version = "0.10", features = ["std"] }
redact-engine.workspace = true redact-engine.workspace = true
url.workspace = true url.workspace = true
regex.workspace = true regex.workspace = true
dotenvy_macro.workspace = true
uzers.workspace = true uzers.workspace = true
serde_urlencoded.workspace = true serde_urlencoded.workspace = true
md5.workspace = true md5.workspace = true

19
crates/gpapi/build.rs Normal file
View File

@@ -0,0 +1,19 @@
use std::path::Path;
fn main() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace_dir = Path::new(manifest_dir).ancestors().nth(2).unwrap();
let gpgui_dir = workspace_dir.parent().unwrap().join("gpgui");
let gp_service_binary = workspace_dir.join("target/debug/gpservice");
let gp_client_binary = workspace_dir.join("target/debug/gpclient");
let gp_auth_binary = workspace_dir.join("target/debug/gpauth");
let gp_gui_helper_binary = workspace_dir.join("target/debug/gpgui-helper");
let gp_gui_binary = gpgui_dir.join("target/debug/gpgui");
println!("cargo:rustc-env=GP_SERVICE_BINARY={}", gp_service_binary.display());
println!("cargo:rustc-env=GP_CLIENT_BINARY={}", gp_client_binary.display());
println!("cargo:rustc-env=GP_AUTH_BINARY={}", gp_auth_binary.display());
println!("cargo:rustc-env=GP_GUI_HELPER_BINARY={}", gp_gui_helper_binary.display());
println!("cargo:rustc-env=GP_GUI_BINARY={}", gp_gui_binary.display());
}

View File

@@ -1,13 +1,19 @@
use anyhow::bail; use std::borrow::{Borrow, Cow};
use log::{info, warn};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{error::AuthDataParseError, utils::base64::decode_to_string};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SamlAuthData { pub struct SamlAuthData {
#[serde(alias = "un")]
username: String, username: String,
prelogin_cookie: Option<String>, prelogin_cookie: Option<String>,
portal_userauthcookie: Option<String>, portal_userauthcookie: Option<String>,
token: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -32,10 +38,11 @@ impl SamlAuthData {
username, username,
prelogin_cookie, prelogin_cookie,
portal_userauthcookie, 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") { match parse_xml_tag(html, "saml-auth-status") {
Some(saml_status) if saml_status == "1" => { Some(saml_status) if saml_status == "1" => {
let username = parse_xml_tag(html, "saml-username"); let username = parse_xml_tag(html, "saml-username");
@@ -43,24 +50,51 @@ impl SamlAuthData {
let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie"); let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie");
if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) { if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
return Ok(SamlAuthData::new( Ok(SamlAuthData::new(
username.unwrap(), username.unwrap(),
prelogin_cookie, prelogin_cookie,
portal_userauthcookie, portal_userauthcookie,
)); ))
} else {
Err(AuthDataParseError::Invalid)
} }
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");
} }
Some(_) => Err(AuthDataParseError::Invalid),
None => Err(AuthDataParseError::NotFound),
} }
} }
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");
// Decode the auth data and use the original value if decoding fails
let auth_data = urlencoding::decode(auth_data).unwrap_or_else(|err| {
warn!("Failed to decode token auth data: {}", err);
Cow::Borrowed(auth_data)
});
let auth_data: SamlAuthData = serde_urlencoded::from_str(auth_data.borrow()).map_err(|e| {
warn!("Failed to parse token auth data: {}", e);
warn!("Auth data: {}", auth_data);
AuthDataParseError::Invalid
})?;
return Ok(auth_data);
}
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)
}
pub fn username(&self) -> &str { pub fn username(&self) -> &str {
&self.username &self.username
} }
@@ -69,6 +103,10 @@ impl SamlAuthData {
self.prelogin_cookie.as_deref() self.prelogin_cookie.as_deref()
} }
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
pub fn check( pub fn check(
username: &Option<String>, username: &Option<String>,
prelogin_cookie: &Option<String>, prelogin_cookie: &Option<String>,
@@ -78,7 +116,16 @@ impl SamlAuthData {
let prelogin_cookie_valid = prelogin_cookie.as_ref().is_some_and(|val| val.len() > 5); let prelogin_cookie_valid = prelogin_cookie.as_ref().is_some_and(|val| val.len() > 5);
let portal_userauthcookie_valid = portal_userauthcookie.as_ref().is_some_and(|val| val.len() > 5); let portal_userauthcookie_valid = portal_userauthcookie.as_ref().is_some_and(|val| val.len() > 5);
username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid) 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 +135,38 @@ pub fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
.and_then(|captures| captures.get(1)) .and_then(|captures| captures.get(1))
.map(|m| m.as_str().to_string()) .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_cas_urlencoded() {
let auth_data = "globalprotectcallback:cas-as%3D1%26un%3Dxyz%40email.com%26token%3Dvery_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 serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use crate::{auth::SamlAuthData, utils::base64::decode_to_string}; use crate::auth::SamlAuthData;
#[derive(Debug, Serialize, Deserialize, Type, Clone)] #[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -37,16 +37,18 @@ impl From<&CachedCredential> for PasswordCredential {
#[derive(Debug, Serialize, Deserialize, Type, Clone)] #[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PreloginCookieCredential { pub struct PreloginCredential {
username: String, username: String,
prelogin_cookie: String, prelogin_cookie: Option<String>,
token: Option<String>,
} }
impl PreloginCookieCredential { impl PreloginCredential {
pub fn new(username: &str, prelogin_cookie: &str) -> Self { pub fn new(username: &str, prelogin_cookie: Option<&str>, token: Option<&str>) -> Self {
Self { Self {
username: username.to_string(), 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 &self.username
} }
pub fn prelogin_cookie(&self) -> &str { pub fn prelogin_cookie(&self) -> Option<&str> {
&self.prelogin_cookie self.prelogin_cookie.as_deref()
}
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
} }
} }
impl TryFrom<SamlAuthData> for PreloginCookieCredential { impl From<SamlAuthData> for PreloginCredential {
type Error = anyhow::Error; fn from(value: SamlAuthData) -> Self {
fn try_from(value: SamlAuthData) -> Result<Self, Self::Error> {
let username = value.username().to_string(); let username = value.username().to_string();
let prelogin_cookie = value let prelogin_cookie = value.prelogin_cookie();
.prelogin_cookie() let token = value.token();
.ok_or_else(|| anyhow::anyhow!("Missing prelogin cookie"))?
.to_string();
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)] #[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
pub enum Credential { pub enum Credential {
Password(PasswordCredential), Password(PasswordCredential),
PreloginCookie(PreloginCookieCredential), Prelogin(PreloginCredential),
AuthCookie(AuthCookieCredential), AuthCookie(AuthCookieCredential),
CachedCredential(CachedCredential), Cached(CachedCredential),
} }
impl Credential { impl Credential {
/// Create a credential from a globalprotectcallback:<base64 encoded string> /// Create a credential from a globalprotectcallback:<base64 encoded string>,
pub fn parse_gpcallback(auth_data: &str) -> anyhow::Result<Self> { /// or globalprotectcallback:cas-as=1&un=user@xyz.com&token=very_long_string
// Remove the surrounding quotes pub fn from_gpcallback(auth_data: &str) -> anyhow::Result<Self> {
let auth_data = auth_data.trim_matches('"'); let auth_data = SamlAuthData::from_gpcallback(auth_data)?;
let auth_data = auth_data.trim_start_matches("globalprotectcallback:");
let auth_data = decode_to_string(auth_data)?;
let auth_data = SamlAuthData::parse_html(&auth_data)?;
Self::try_from(auth_data) Ok(Self::from(auth_data))
} }
pub fn username(&self) -> &str { pub fn username(&self) -> &str {
match self { match self {
Credential::Password(cred) => cred.username(), Credential::Password(cred) => cred.username(),
Credential::PreloginCookie(cred) => cred.username(), Credential::Prelogin(cred) => cred.username(),
Credential::AuthCookie(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(); let mut params = HashMap::new();
params.insert("user", self.username()); params.insert("user", self.username());
let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie) = match self { let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie, token) = match self {
Credential::Password(cred) => (Some(cred.password()), None, None, None), Credential::Password(cred) => (Some(cred.password()), None, None, None, None),
Credential::PreloginCookie(cred) => (None, Some(cred.prelogin_cookie()), None, None), Credential::Prelogin(cred) => (None, cred.prelogin_cookie(), None, None, cred.token()),
Credential::AuthCookie(cred) => ( Credential::AuthCookie(cred) => (
None, None,
None, None,
Some(cred.user_auth_cookie()), Some(cred.user_auth_cookie()),
Some(cred.prelogon_user_auth_cookie()), Some(cred.prelogon_user_auth_cookie()),
None,
), ),
Credential::CachedCredential(cred) => ( Credential::Cached(cred) => (
cred.password(), cred.password(),
None, None,
Some(cred.auth_cookie.user_auth_cookie()), Some(cred.auth_cookie.user_auth_cookie()),
Some(cred.auth_cookie.prelogon_user_auth_cookie()), Some(cred.auth_cookie.prelogon_user_auth_cookie()),
None,
), ),
}; };
@@ -214,17 +214,19 @@ impl Credential {
portal_prelogonuserauthcookie.unwrap_or_default(), portal_prelogonuserauthcookie.unwrap_or_default(),
); );
if let Some(token) = token {
params.insert("token", token);
}
params params
} }
} }
impl TryFrom<SamlAuthData> for Credential { impl From<SamlAuthData> for Credential {
type Error = anyhow::Error; fn from(value: SamlAuthData) -> Self {
let cred = PreloginCredential::from(value);
fn try_from(value: SamlAuthData) -> Result<Self, Self::Error> { Self::Prelogin(cred)
let prelogin_cookie = PreloginCookieCredential::try_from(value)?;
Ok(Self::PreloginCookie(prelogin_cookie))
} }
} }
@@ -242,6 +244,6 @@ impl From<&AuthCookieCredential> for Credential {
impl From<&CachedCredential> for Credential { impl From<&CachedCredential> for Credential {
fn from(value: &CachedCredential) -> Self { fn from(value: &CachedCredential) -> Self {
Self::CachedCredential(value.clone()) Self::Cached(value.clone())
} }
} }

View File

@@ -2,10 +2,18 @@ use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum PortalError { pub enum PortalError {
#[error("Portal prelogin error: {0}")] #[error("Prelogin error: {0}")]
PreloginError(String), PreloginError(String),
#[error("Portal config error: {0}")] #[error("Portal config error: {0}")]
ConfigError(String), ConfigError(String),
#[error("Gateway error: {0}")] #[error("Network error: {0}")]
GatewayError(String), 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<()> { pub async fn hip_report(gateway: &str, cookie: &str, csd_wrapper: &str, gp_params: &GpParams) -> anyhow::Result<()> {
let client = Client::builder() let client = Client::try_from(gp_params)?;
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let md5 = build_csd_token(cookie)?; let md5 = build_csd_token(cookie)?;
info!("Submit HIP report md5: {}", md5); info!("Submit HIP report md5: {}", md5);

View File

@@ -8,18 +8,20 @@ use crate::{
credential::Credential, credential::Credential,
error::PortalError, error::PortalError,
gp_params::GpParams, gp_params::GpParams,
utils::{normalize_server, parse_gp_error, 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 url = normalize_server(gateway)?;
let gateway = remove_url_scheme(&url); let gateway = remove_url_scheme(&url);
let login_url = format!("{}/ssl-vpn/login.esp", url); let login_url = format!("{}/ssl-vpn/login.esp", url);
let client = Client::builder() let client = Client::try_from(gp_params)?;
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let mut params = cred.to_params(); let mut params = cred.to_params();
let extra_params = gp_params.to_params(); let extra_params = gp_params.to_params();
@@ -34,25 +36,27 @@ pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParam
.form(&params) .form(&params)
.send() .send()
.await .await
.map_err(|e| anyhow::anyhow!(PortalError::GatewayError(e.to_string())))?; .map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
let status = res.status(); let res = parse_gp_response(res).await.map_err(|err| {
warn!("{err}");
anyhow::anyhow!("Gateway login error: {}", err.reason)
})?;
if status.is_client_error() || status.is_server_error() { // MFA detected
let (reason, res) = parse_gp_error(res).await; if res.contains("Challenge") {
let Some((message, input_str)) = parse_mfa(&res) else {
bail!("Failed to parse MFA challenge: {res}");
};
warn!( return Ok(GatewayLogin::Mfa(message, input_str));
"Gateway login error: reason={}, status={}, response={}",
reason, status, res
);
bail!("Gateway login error, reason: {}", reason);
} }
let res_xml = res.text().await?; let doc = Document::parse(&res)?;
let doc = Document::parse(&res_xml)?;
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> { fn build_gateway_token(doc: &Document, computer: &str) -> anyhow::Result<String> {
@@ -86,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")) .ok_or_else(|| anyhow::anyhow!("Failed to read {key} from args"))
.map(|s| (key, s.as_ref())) .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 std::collections::HashMap;
use log::info;
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use crate::GP_USER_AGENT; use crate::{utils::request::create_identity, GP_USER_AGENT};
#[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)]
pub enum ClientOs { 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 { pub struct GpParams {
is_gateway: bool, is_gateway: bool,
user_agent: String, user_agent: String,
@@ -51,7 +53,12 @@ pub struct GpParams {
client_version: Option<String>, client_version: Option<String>,
computer: String, computer: String,
ignore_tls_errors: bool, 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 { impl GpParams {
@@ -79,10 +86,6 @@ impl GpParams {
self.ignore_tls_errors self.ignore_tls_errors
} }
pub fn prefer_default_browser(&self) -> bool {
self.prefer_default_browser
}
pub fn client_os(&self) -> &str { pub fn client_os(&self) -> &str {
self.client_os.as_str() self.client_os.as_str()
} }
@@ -95,6 +98,14 @@ impl GpParams {
self.client_version.as_deref() 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> { pub(crate) fn to_params(&self) -> HashMap<&str, &str> {
let mut params: HashMap<&str, &str> = HashMap::new(); let mut params: HashMap<&str, &str> = HashMap::new();
let client_os = self.client_os.as_str(); let client_os = self.client_os.as_str();
@@ -105,11 +116,16 @@ impl GpParams {
params.insert("ok", "Login"); params.insert("ok", "Login");
params.insert("direct", "yes"); params.insert("direct", "yes");
params.insert("ipv6-support", "yes"); params.insert("ipv6-support", "yes");
params.insert("inputStr", "");
params.insert("clientVer", "4100"); params.insert("clientVer", "4100");
params.insert("clientos", client_os); params.insert("clientos", client_os);
params.insert("computer", &self.computer); 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 { if let Some(os_version) = &self.os_version {
params.insert("os-version", os_version); params.insert("os-version", os_version);
} }
@@ -131,20 +147,26 @@ pub struct GpParamsBuilder {
client_version: Option<String>, client_version: Option<String>,
computer: String, computer: String,
ignore_tls_errors: bool, ignore_tls_errors: bool,
prefer_default_browser: bool, certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
} }
impl GpParamsBuilder { impl GpParamsBuilder {
pub fn new() -> Self { pub fn new() -> Self {
let computer = whoami::fallible::hostname().unwrap_or_else(|_| String::from("localhost"));
Self { Self {
is_gateway: false, is_gateway: false,
user_agent: GP_USER_AGENT.to_string(), user_agent: GP_USER_AGENT.to_string(),
client_os: ClientOs::Linux, client_os: ClientOs::Linux,
os_version: Default::default(), os_version: Default::default(),
client_version: Default::default(), client_version: Default::default(),
computer: whoami::hostname(), computer,
ignore_tls_errors: false, 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 self
} }
pub fn prefer_default_browser(&mut self, prefer_default_browser: bool) -> &mut Self { pub fn certificate<T: Into<Option<String>>>(&mut self, certificate: T) -> &mut Self {
self.prefer_default_browser = prefer_default_browser; 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 self
} }
@@ -197,7 +229,11 @@ impl GpParamsBuilder {
client_version: self.client_version.clone(), client_version: self.client_version.clone(),
computer: self.computer.clone(), computer: self.computer.clone(),
ignore_tls_errors: self.ignore_tls_errors, 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() 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

@@ -29,12 +29,12 @@ pub const GP_GUI_HELPER_BINARY: &str = "/usr/bin/gpgui-helper";
pub(crate) const GP_AUTH_BINARY: &str = "/usr/bin/gpauth"; pub(crate) const GP_AUTH_BINARY: &str = "/usr/bin/gpauth";
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub const GP_CLIENT_BINARY: &str = dotenvy_macro::dotenv!("GP_CLIENT_BINARY"); pub const GP_CLIENT_BINARY: &str = env!("GP_CLIENT_BINARY");
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub const GP_SERVICE_BINARY: &str = dotenvy_macro::dotenv!("GP_SERVICE_BINARY"); pub const GP_SERVICE_BINARY: &str = env!("GP_SERVICE_BINARY");
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub const GP_GUI_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_BINARY"); pub const GP_GUI_BINARY: &str = env!("GP_GUI_BINARY");
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub const GP_GUI_HELPER_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_HELPER_BINARY"); pub const GP_GUI_HELPER_BINARY: &str = env!("GP_GUI_HELPER_BINARY");
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub(crate) const GP_AUTH_BINARY: &str = dotenvy_macro::dotenv!("GP_AUTH_BINARY"); pub(crate) const GP_AUTH_BINARY: &str = env!("GP_AUTH_BINARY");

View File

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

View File

@@ -1,4 +1,4 @@
use anyhow::bail; use anyhow::{anyhow, bail};
use log::{info, warn}; use log::{info, warn};
use reqwest::{Client, StatusCode}; use reqwest::{Client, StatusCode};
use roxmltree::Document; use roxmltree::Document;
@@ -8,7 +8,7 @@ use specta::Type;
use crate::{ use crate::{
error::PortalError, error::PortalError,
gp_params::GpParams, gp_params::GpParams,
utils::{base64, normalize_server, parse_gp_error, xml}, utils::{base64, normalize_server, parse_gp_response, xml},
}; };
const REQUIRED_PARAMS: [&str; 8] = [ const REQUIRED_PARAMS: [&str; 8] = [
@@ -98,60 +98,61 @@ impl Prelogin {
pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> { pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> {
let user_agent = gp_params.user_agent(); 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 portal = normalize_server(portal)?;
let is_gateway = gp_params.is_gateway();
let path = if is_gateway { "ssl-vpn" } else { "global-protect" }; let path = if is_gateway { "ssl-vpn" } else { "global-protect" };
let prelogin_url = format!("{portal}/{}/prelogin.esp", path); let prelogin_url = format!("{portal}/{}/prelogin.esp", path);
let mut params = gp_params.to_params(); let mut params = gp_params.to_params();
params.insert("tmp", "tmp"); 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)); params.retain(|k, _| REQUIRED_PARAMS.iter().any(|required_param| required_param == k));
let client = Client::builder() let client = Client::try_from(gp_params)?;
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(user_agent)
.build()?;
let res = client.post(&prelogin_url).form(&params).send().await?; let res = client
let status = res.status(); .post(&prelogin_url)
.form(&params)
if status == StatusCode::NOT_FOUND { .send()
bail!(PortalError::PreloginError("Prelogin endpoint not found".to_string()))
}
if status.is_client_error() || status.is_server_error() {
let (reason, res) = parse_gp_error(res).await;
warn!("Prelogin error: reason={}, status={}, response={}", reason, status, res);
bail!("Prelogin error: {}", status)
}
let res_xml = res
.text()
.await .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) Ok(prelogin)
} }
fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin> { fn parse_res_xml(res_xml: &str, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(&res_xml)?; let doc = Document::parse(res_xml)?;
let status = xml::get_child_text(&doc, "status") let status = xml::get_child_text(&doc, "status")
.ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain status element"))?; .ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain status element"))?;
// Check the status of the prelogin response // Check the status of the prelogin response
if status.to_uppercase() != "SUCCESS" { if status.to_uppercase() != "SUCCESS" {
let msg = xml::get_child_text(&doc, "msg").unwrap_or(String::from("Unknown error")); 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(|| { let region = xml::get_child_text(&doc, "region").unwrap_or_else(|| {
@@ -177,22 +178,24 @@ fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin>
return Ok(Prelogin::Saml(saml_prelogin)); return Ok(Prelogin::Saml(saml_prelogin));
} }
let label_username = xml::get_child_text(&doc, "username-label"); let label_username = xml::get_child_text(&doc, "username-label").unwrap_or_else(|| {
let label_password = xml::get_child_text(&doc, "password-label"); info!("Username label has no value, using default");
// Check if the prelogin response is standard login String::from("Username")
if label_username.is_some() && label_password.is_some() { });
let auth_message = let label_password = xml::get_child_text(&doc, "password-label").unwrap_or_else(|| {
xml::get_child_text(&doc, "authentication-message").unwrap_or(String::from("Please enter the login credentials")); info!("Password label has no value, using default");
let standard_prelogin = StandardPrelogin { String::from("Password")
region, });
is_gateway,
auth_message,
label_username: label_username.unwrap(),
label_password: label_password.unwrap(),
};
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, fix_openssl: bool,
ignore_tls_errors: bool, ignore_tls_errors: bool,
clean: bool, clean: bool,
default_browser: bool,
} }
impl<'a> SamlAuthLauncher<'a> { impl<'a> SamlAuthLauncher<'a> {
@@ -33,6 +34,7 @@ impl<'a> SamlAuthLauncher<'a> {
fix_openssl: false, fix_openssl: false,
ignore_tls_errors: false, ignore_tls_errors: false,
clean: false, clean: false,
default_browser: false,
} }
} }
@@ -81,8 +83,13 @@ impl<'a> SamlAuthLauncher<'a> {
self 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. /// 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); let mut auth_cmd = Command::new(GP_AUTH_BINARY);
auth_cmd.arg(self.server); auth_cmd.arg(self.server);
@@ -122,6 +129,10 @@ impl<'a> SamlAuthLauncher<'a> {
auth_cmd.arg("--clean"); auth_cmd.arg("--clean");
} }
if self.default_browser {
auth_cmd.arg("--default-browser");
}
let mut non_root_cmd = auth_cmd.into_non_root()?; let mut non_root_cmd = auth_cmd.into_non_root()?;
let output = non_root_cmd let output = non_root_cmd
.kill_on_drop(true) .kill_on_drop(true)
@@ -130,12 +141,16 @@ impl<'a> SamlAuthLauncher<'a> {
.wait_with_output() .wait_with_output()
.await?; .await?;
if self.default_browser {
return Ok(None);
}
let Ok(auth_result) = serde_json::from_slice::<SamlAuthResult>(&output.stdout) else { let Ok(auth_result) = serde_json::from_slice::<SamlAuthResult>(&output.stdout) else {
bail!("Failed to parse auth data") bail!("Failed to parse auth data")
}; };
match auth_result { 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), 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> { pub struct BrowserAuthenticator<'a> {
auth_request: &'a str, auth_request: &'a str,
@@ -14,8 +17,18 @@ impl BrowserAuthenticator<'_> {
open::that_detached(self.auth_request)?; open::that_detached(self.auth_request)?;
} else { } else {
let html_file = temp_dir().join("gpauth.html"); 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())?; file.write_all(self.auth_request.as_bytes())?;
open::that_detached(html_file)?; open::that_detached(html_file)?;
@@ -24,11 +37,3 @@ impl BrowserAuthenticator<'_> {
Ok(()) 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

@@ -32,10 +32,15 @@ pub struct ConnectArgs {
cookie: String, cookie: String,
vpnc_script: Option<String>, vpnc_script: Option<String>,
user_agent: Option<String>, user_agent: Option<String>,
os: Option<ClientOs>,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
csd_uid: u32, csd_uid: u32,
csd_wrapper: Option<String>, csd_wrapper: Option<String>,
reconnect_timeout: u32,
mtu: u32, mtu: u32,
os: Option<ClientOs>, disable_ipv6: bool,
} }
impl ConnectArgs { impl ConnectArgs {
@@ -45,9 +50,14 @@ impl ConnectArgs {
vpnc_script: None, vpnc_script: None,
user_agent: None, user_agent: None,
os: None, os: None,
certificate: None,
sslkey: None,
key_password: None,
csd_uid: 0, csd_uid: 0,
csd_wrapper: None, csd_wrapper: None,
reconnect_timeout: 300,
mtu: 0, mtu: 0,
disable_ipv6: false,
} }
} }
@@ -67,6 +77,18 @@ impl ConnectArgs {
self.os.as_ref().map(|os| os.to_openconnect_os().to_string()) 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 { pub fn csd_uid(&self) -> u32 {
self.csd_uid self.csd_uid
} }
@@ -75,9 +97,17 @@ impl ConnectArgs {
self.csd_wrapper.clone() self.csd_wrapper.clone()
} }
pub fn reconnect_timeout(&self) -> u32 {
self.reconnect_timeout
}
pub fn mtu(&self) -> u32 { pub fn mtu(&self) -> u32 {
self.mtu self.mtu
} }
pub fn disable_ipv6(&self) -> bool {
self.disable_ipv6
}
} }
#[derive(Debug, Deserialize, Serialize, Type)] #[derive(Debug, Deserialize, Serialize, Type)]
@@ -109,11 +139,6 @@ impl ConnectRequest {
self self
} }
pub fn with_mtu(mut self, mtu: u32) -> Self {
self.args.mtu = mtu;
self
}
pub fn with_user_agent<T: Into<Option<String>>>(mut self, user_agent: T) -> Self { pub fn with_user_agent<T: Into<Option<String>>>(mut self, user_agent: T) -> Self {
self.args.user_agent = user_agent.into(); self.args.user_agent = user_agent.into();
self self
@@ -124,6 +149,36 @@ impl ConnectRequest {
self 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 { pub fn gateway(&self) -> &Gateway {
self.info.gateway() self.info.gateway()
} }

View File

@@ -1,5 +1,3 @@
use reqwest::{Response, Url};
pub(crate) mod xml; pub(crate) mod xml;
pub mod base64; pub mod base64;
@@ -10,13 +8,18 @@ pub mod env_file;
pub mod lock_file; pub mod lock_file;
pub mod openssl; pub mod openssl;
pub mod redact; pub mod redact;
pub mod request;
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
pub mod window; pub mod window;
mod shutdown_signal; mod shutdown_signal;
use log::warn;
pub use shutdown_signal::shutdown_signal; pub use shutdown_signal::shutdown_signal;
use reqwest::{Response, StatusCode, Url};
use thiserror::Error;
/// Normalize the server URL to the format `https://<host>:<port>` /// Normalize the server URL to the format `https://<host>:<port>`
pub fn normalize_server(server: &str) -> anyhow::Result<String> { pub fn normalize_server(server: &str) -> anyhow::Result<String> {
let server = if server.starts_with("https://") || server.starts_with("http://") { let server = if server.starts_with("https://") || server.starts_with("http://") {
@@ -42,7 +45,41 @@ pub fn remove_url_scheme(s: &str) -> String {
s.replace("http://", "").replace("https://", "") s.replace("http://", "").replace("https://", "")
} }
pub(crate) async fn parse_gp_error(res: Response) -> (String, String) { #[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 let reason = res
.headers() .headers()
.get("x-private-pan-globalprotect") .get("x-private-pan-globalprotect")

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

@@ -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

@@ -14,12 +14,16 @@ pub(crate) struct ConnectOptions {
pub script: *const c_char, pub script: *const c_char,
pub os: *const c_char, pub os: *const c_char,
pub certificate: *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 servercert: *const c_char,
pub csd_uid: u32, pub csd_uid: u32,
pub csd_wrapper: *const c_char, pub csd_wrapper: *const c_char,
pub reconnect_timeout: u32,
pub mtu: u32, pub mtu: u32,
pub disable_ipv6: u32,
} }
#[link(name = "vpn")] #[link(name = "vpn")]

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ use std::{
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
use common::vpn_utils::{find_vpnc_script, is_executable}; use common::vpn_utils::{check_executable, find_vpnc_script};
use log::info; use log::info;
use crate::ffi; use crate::ffi;
@@ -18,12 +18,16 @@ pub struct Vpn {
script: CString, script: CString,
os: CString, os: CString,
certificate: Option<CString>, certificate: Option<CString>,
sslkey: Option<CString>,
key_password: Option<CString>,
servercert: Option<CString>, servercert: Option<CString>,
csd_uid: u32, csd_uid: u32,
csd_wrapper: Option<CString>, csd_wrapper: Option<CString>,
reconnect_timeout: u32,
mtu: u32, mtu: u32,
disable_ipv6: bool,
callback: OnConnectedCallback, callback: OnConnectedCallback,
} }
@@ -61,13 +65,18 @@ impl Vpn {
user_agent: self.user_agent.as_ptr(), user_agent: self.user_agent.as_ptr(),
script: self.script.as_ptr(), script: self.script.as_ptr(),
os: self.os.as_ptr(), os: self.os.as_ptr(),
certificate: Self::option_to_ptr(&self.certificate), 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), servercert: Self::option_to_ptr(&self.servercert),
csd_uid: self.csd_uid, csd_uid: self.csd_uid,
csd_wrapper: Self::option_to_ptr(&self.csd_wrapper), csd_wrapper: Self::option_to_ptr(&self.csd_wrapper),
reconnect_timeout: self.reconnect_timeout,
mtu: self.mtu, mtu: self.mtu,
disable_ipv6: self.disable_ipv6 as u32,
} }
} }
@@ -80,23 +89,23 @@ impl Vpn {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct VpnError<'a> { pub struct VpnError {
message: &'a str, message: String,
} }
impl<'a> VpnError<'a> { impl VpnError {
fn new(message: &'a str) -> Self { fn new(message: String) -> Self {
Self { message } Self { message }
} }
} }
impl fmt::Display for VpnError<'_> { impl fmt::Display for VpnError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.message) write!(f, "{}", self.message)
} }
} }
impl std::error::Error for VpnError<'_> {} impl std::error::Error for VpnError {}
pub struct VpnBuilder { pub struct VpnBuilder {
server: String, server: String,
@@ -106,10 +115,16 @@ pub struct VpnBuilder {
user_agent: Option<String>, user_agent: Option<String>,
os: Option<String>, os: Option<String>,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
csd_uid: u32, csd_uid: u32,
csd_wrapper: Option<String>, csd_wrapper: Option<String>,
reconnect_timeout: u32,
mtu: u32, mtu: u32,
disable_ipv6: bool,
} }
impl VpnBuilder { impl VpnBuilder {
@@ -122,10 +137,16 @@ impl VpnBuilder {
user_agent: None, user_agent: None,
os: None, os: None,
certificate: None,
sslkey: None,
key_password: None,
csd_uid: 0, csd_uid: 0,
csd_wrapper: None, csd_wrapper: None,
reconnect_timeout: 300,
mtu: 0, mtu: 0,
disable_ipv6: false,
} }
} }
@@ -144,6 +165,21 @@ impl VpnBuilder {
self 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 { pub fn csd_uid(mut self, csd_uid: u32) -> Self {
self.csd_uid = csd_uid; self.csd_uid = csd_uid;
self self
@@ -154,26 +190,32 @@ impl VpnBuilder {
self self
} }
pub fn reconnect_timeout(mut self, reconnect_timeout: u32) -> Self {
self.reconnect_timeout = reconnect_timeout;
self
}
pub fn mtu(mut self, mtu: u32) -> Self { pub fn mtu(mut self, mtu: u32) -> Self {
self.mtu = mtu; self.mtu = mtu;
self self
} }
pub fn build(self) -> Result<Vpn, VpnError<'static>> { 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 { let script = match self.script {
Some(script) => { Some(script) => {
if !is_executable(&script) { check_executable(&script).map_err(|e| VpnError::new(e.to_string()))?;
return Err(VpnError::new("vpnc script is not executable"));
}
script script
} }
None => find_vpnc_script().ok_or_else(|| VpnError::new("Failed to find vpnc-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 { if let Some(csd_wrapper) = &self.csd_wrapper {
if !is_executable(csd_wrapper) { check_executable(csd_wrapper).map_err(|e| VpnError::new(e.to_string()))?;
return Err(VpnError::new("CSD wrapper is not executable"));
}
} }
let user_agent = self.user_agent.unwrap_or_default(); let user_agent = self.user_agent.unwrap_or_default();
@@ -185,13 +227,18 @@ impl VpnBuilder {
user_agent: Self::to_cstring(&user_agent), user_agent: Self::to_cstring(&user_agent),
script: Self::to_cstring(&script), script: Self::to_cstring(&script),
os: Self::to_cstring(&os), 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, servercert: None,
csd_uid: self.csd_uid, csd_uid: self.csd_uid,
csd_wrapper: self.csd_wrapper.as_deref().map(Self::to_cstring), csd_wrapper: self.csd_wrapper.as_deref().map(Self::to_cstring),
reconnect_timeout: self.reconnect_timeout,
mtu: self.mtu, mtu: self.mtu,
disable_ipv6: self.disable_ipv6,
callback: Default::default(), callback: Default::default(),
}) })

View File

@@ -5,7 +5,10 @@ install:
install -Dm755 artifacts/usr/bin/gpservice $(DESTDIR)/usr/bin/gpservice 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/gpauth $(DESTDIR)/usr/bin/gpauth
install -Dm755 artifacts/usr/bin/gpgui-helper $(DESTDIR)/usr/bin/gpgui-helper install -Dm755 artifacts/usr/bin/gpgui-helper $(DESTDIR)/usr/bin/gpgui-helper
install -Dm755 artifacts/usr/bin/gpgui $(DESTDIR)/usr/bin/gpgui
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/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/scalable/apps/gpgui.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/gpgui.svg

51
scripts/gh-release.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# Usage: ./scripts/gh-release.sh <tag>
set -e
REPO="yuezk/GlobalProtect-openconnect"
TAG=$1
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
RELEASE_NOTES="Release $TAG"
if [ -z "$TAG" ]; then
echo "Usage: ./scripts/gh-release.sh <tag>"
exit 1
fi
# For snapshot release, we don't create a release, just clear the existing assets and upload new ones.
# This is to avoid notification spam.
release_snapshot() {
RELEASE_NOTES='**!!! DO NOT USE THIS RELEASE IN PRODUCTION !!!**'
# Get the existing assets
gh -R "$REPO" release view "$TAG" --json assets --jq '.assets[].name' \
| xargs -I {} gh -R "$REPO" release delete-asset "$TAG" {} --yes
echo "Uploading new assets..."
gh -R "$REPO" release upload "$TAG" \
"$PROJECT_DIR"/.build/artifacts/artifact-source/* \
"$PROJECT_DIR"/.build/artifacts/artifact-gpgui-*/*
}
release_tag() {
echo "Removing existing release..."
gh -R "$REPO" release delete $TAG --yes --cleanup-tag || true
echo "Creating release..."
gh -R "$REPO" release create $TAG \
--title "$TAG" \
--notes "$RELEASE_NOTES" \
"$PROJECT_DIR"/.build/artifacts/artifact-source/* \
"$PROJECT_DIR"/.build/artifacts/artifact-gpgui-*/*
}
if [[ $TAG == *"snapshot" ]]; then
release_snapshot
else
release_tag
fi