Compare commits

..

16 Commits

Author SHA1 Message Date
Kevin Yue
db9249bd61 Support HIP report (#309) 2024-02-05 18:35:45 +08:00
Kevin Yue
662e4d0b8a Support specify csd-wrapper 2024-02-03 13:12:17 +08:00
Kevin Yue
13be9179f5 Bump version 2.0.0 2024-02-01 22:41:36 +08:00
Kevin Yue
0a55506077 Do not error when region is not found 2024-02-01 21:52:31 +08:00
Kevin Yue
8860efa82e Simplify code 2024-01-29 07:54:58 -05:00
Kevin Yue
9bc0994a8e Update gpauth app icon 2024-01-29 06:10:34 -05:00
Kevin Yue
1f50e4d82b Add CI 2024-01-28 20:34:15 +08:00
Kevin Yue
995d1216ea Bump version 2.0.0-beta8 2024-01-28 20:21:33 +08:00
Kevin Yue
196e91289c Update format 2024-01-28 05:11:46 -05:00
Kevin Yue
b2bb35994f Support connect gateway (#306) 2024-01-28 11:41:48 +08:00
Kevin Yue
6fe6a1387a Update README.md 2024-01-25 20:30:23 +08:00
Kevin Yue
aac401e7ee Perform gateway prelogin when failed to login to gateway 2024-01-23 09:26:45 -05:00
Kevin Yue
9655b735a1 Fix ignore TLS errors 2024-01-22 23:20:25 -05:00
Kevin Yue
c3bd7aeb93 Support SSO using default browser 2024-01-22 09:43:44 -05:00
Kevin Yue
0b55a80317 Bump version 2.0.0-beta4 2024-01-21 11:05:15 -05:00
Kevin Yue
c6315bf384 Handle auth window auth fail 2024-01-21 11:04:35 -05:00
52 changed files with 1138 additions and 352 deletions

View File

@@ -8,8 +8,9 @@ on:
- .devcontainer
branches:
- main
# tags:
# - v*.*.*
tags:
- latest
- v*.*.*
jobs:
# Include arm64 if ref is a tag
setup-matrix:
@@ -30,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
@@ -54,43 +55,35 @@ jobs:
pnpm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: gpgui-fe
path: app/dist
build-tauri:
needs: [setup-matrix, build-fe]
build-tauri-amd64:
needs: [build-fe]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Checkout gp repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
path: gp
- name: Download gpgui-fe artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: gpgui-fe
path: gpgui/app/dist
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -104,13 +97,212 @@ jobs:
-v $(pwd):/${{ github.workspace }} \
-w ${{ github.workspace }} \
-e CI=true \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:main \
"./gpgui/scripts/build.sh"
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-tauri
name: artifact-amd64-tauri
path: |
gpgui/.tmp/artifact
build-tauri-arm64:
if: startsWith(github.ref, 'refs/tags/')
needs: [build-fe]
runs-on: self-hosted
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Checkout gp repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/GlobalProtect-openconnect
path: gp
- name: Download gpgui-fe artifact
uses: actions/download-artifact@v3
with:
name: gpgui-fe
path: gpgui/app/dist
- name: Build Tauri
run: |
./gpgui/scripts/build.sh
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: artifact-arm64-tauri
path: |
gpgui/.tmp/artifact
package-tarball:
needs: [build-tauri-amd64, build-tauri-arm64]
runs-on: ubuntu-latest
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-amd64-tauri
uses: actions/download-artifact@v3
with:
name: artifact-amd64-tauri
path: gpgui/.tmp/artifact
- name: Download artifact-arm64-tauri
uses: actions/download-artifact@v3
with:
name: artifact-arm64-tauri
path: gpgui/.tmp/artifact
- name: Create tarball
run: |
./gpgui/scripts/build-tarball.sh
- name: Upload tarball
uses: actions/upload-artifact@v3
with:
name: artifact-tarball
path: |
gpgui/.tmp/tarball/*.tar.gz
package-rpm:
needs: [setup-matrix, package-tarball]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download package tarball
uses: actions/download-artifact@v3
with:
name: artifact-tarball
path: gpgui/.tmp/artifact
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Create RPM package
run: |
docker run \
--rm \
-v $(pwd):/${{ github.workspace }} \
-w ${{ github.workspace }} \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:rpm-builder \
"./gpgui/scripts/build-rpm.sh"
- name: Upload rpm artifacts
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-rpm
path: |
gpgui/.tmp/artifact/*.rpm
package-pkgbuild:
needs: [setup-matrix, build-tauri-amd64, build-tauri-arm64]
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout gpgui repo
uses: actions/checkout@v3
with:
token: ${{ secrets.GH_PAT }}
repository: yuezk/gpgui
path: gpgui
- name: Download artifact-${{ matrix.arch }}
uses: actions/download-artifact@v3
with:
name: artifact-${{ matrix.arch }}-tauri
path: gpgui/.tmp/artifact
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate PKGBUILD
run: |
export CI_ARCH=${{ matrix.arch }}
./gpgui/scripts/generate-pkgbuild.sh
- name: Build PKGBUILD package
run: |
# Build package
docker run \
--rm \
-v $(pwd)/gpgui/.tmp/pkgbuild:/pkgbuild \
--platform linux/${{ matrix.arch }} \
yuezk/gpdev:pkgbuild
- name: Upload pkgbuild artifacts
uses: actions/upload-artifact@v3
with:
name: artifact-${{ matrix.arch }}-pkgbuild
path: |
gpgui/.tmp/pkgbuild/*.pkg.tar.zst
gh-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs:
- package-rpm
- package-pkgbuild
steps:
- name: Download artifact
uses: actions/download-artifact@v3
with:
path: artifact
# pattern: artifact-*
# merge-multiple: true
# - name: Generate checksum
# uses: jmgilman/actions-generate-checksum@v1
# with:
# output: checksums.txt
# patterns: |
# artifact/*
- name: Create GH release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GH_PAT }}
prerelease: ${{ contains(github.ref, 'latest') }}
fail_on_unmatched_files: true
files: |
artifact/artifact-*/*

View File

@@ -4,6 +4,7 @@
"bincode",
"chacha",
"clientos",
"cstring",
"datetime",
"disconnectable",
"distro",
@@ -11,8 +12,10 @@
"dotenvy",
"getconfig",
"globalprotect",
"globalprotectcallback",
"gpapi",
"gpauth",
"gpcallback",
"gpclient",
"gpcommon",
"gpgui",
@@ -50,5 +53,6 @@
"wmctrl",
"XAUTHORITY",
"yuezk"
]
],
"rust-analyzer.cargo.features": "all",
}

55
Cargo.lock generated
View File

@@ -1423,7 +1423,7 @@ dependencies = [
[[package]]
name = "gpapi"
version = "2.0.0-beta3"
version = "2.0.0"
dependencies = [
"anyhow",
"base64 0.21.5",
@@ -1431,12 +1431,15 @@ dependencies = [
"clap",
"dotenvy_macro",
"log",
"md5",
"open",
"redact-engine",
"regex",
"reqwest",
"roxmltree",
"serde",
"serde_json",
"serde_urlencoded",
"specta",
"specta-macros",
"tauri",
@@ -1451,7 +1454,7 @@ dependencies = [
[[package]]
name = "gpauth"
version = "2.0.0-beta3"
version = "2.0.0"
dependencies = [
"anyhow",
"clap",
@@ -1471,7 +1474,7 @@ dependencies = [
[[package]]
name = "gpclient"
version = "2.0.0-beta3"
version = "2.0.0"
dependencies = [
"anyhow",
"clap",
@@ -1492,7 +1495,7 @@ dependencies = [
[[package]]
name = "gpservice"
version = "2.0.0-beta3"
version = "2.0.0"
dependencies = [
"anyhow",
"axum",
@@ -1963,6 +1966,15 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.10"
@@ -1974,6 +1986,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_executable"
version = "1.0.1"
@@ -2206,6 +2228,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.7.1"
@@ -2445,9 +2473,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "open"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90878fb664448b54c4e592455ad02831e23a3f7e157374a8b95654731aac7349"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openconnect"
version = "2.0.0-beta3"
version = "2.0.0"
dependencies = [
"cc",
"is_executable",
@@ -2574,6 +2613,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pathdiff"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]]
name = "percent-encoding"
version = "2.3.1"

View File

@@ -4,7 +4,7 @@ resolver = "2"
members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"]
[workspace.package]
version = "2.0.0-beta3"
version = "2.0.0"
authors = ["Kevin Yue <k3vinyue@gmail.com>"]
homepage = "https://github.com/yuezk/GlobalProtect-openconnect"
edition = "2021"
@@ -43,6 +43,8 @@ thiserror = "1"
redact-engine = "0.1"
dotenvy_macro = "0.15"
compile-time = "0.2"
serde_urlencoded = "0.7"
md5="0.7"
[profile.release]
opt-level = 'z' # Optimize for size

View File

@@ -11,8 +11,11 @@ A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authenticati
- [x] Better Linux support
- [x] Support both CLI and GUI
- [x] Support both SSO and non-SSO authentication
- [x] Support the FIDO2 authentication (e.g., YubiKey)
- [x] Support authentication using default browser
- [x] Support multiple portals
- [x] Support gateway selection
- [x] Support connect gateway directly
- [x] Support auto-connect on startup
- [x] Support system tray icon
@@ -122,6 +125,13 @@ Download the latest RPM package from [releases](https://github.com/yuezk/GlobalP
The project depends on `openconnect >= 8.20`, `webkit2gtk`, `libsecret`, `libayatana-appindicator` or `libappindicator-gtk3`. You can install them first and then download the latest binary release (i.e., `*.bin.tar.gz`) from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page.
## About Trial
The CLI version is always free, while the GUI version is paid. There are two trial modes for the GUI version:
1. 10-day trial: You can use the GUI stable release for 10 days after the installation.
2. 14-day trial: Each beta release has a fresh trial period (at most 14 days) after released.
## [License](./LICENSE)
GPLv3

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -19,8 +19,8 @@ use tokio_util::sync::CancellationToken;
use webkit2gtk::{
gio::Cancellable,
glib::{GString, TimeSpan},
LoadEvent, SettingsExt, TLSErrorsPolicy, URIResponse, URIResponseExt, WebContextExt, WebResource,
WebResourceExt, WebView, WebViewExt, WebsiteDataManagerExtManual, WebsiteDataTypes,
LoadEvent, SettingsExt, TLSErrorsPolicy, URIResponse, URIResponseExt, WebContextExt, WebResource, WebResourceExt,
WebView, WebViewExt, WebsiteDataManagerExtManual, WebsiteDataTypes,
};
enum AuthDataError {
@@ -203,7 +203,8 @@ impl<'a> AuthWindow<'a> {
wv.connect_load_failed(move |_wv, _event, uri, err| {
let redacted_uri = redact_uri(uri);
warn!("Failed to load uri: {} with error: {}", redacted_uri, err);
send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
// NOTE: Don't send error here, since load_changed event will be triggered after this
// send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
// true to stop other handlers from being invoked for the event. false to propagate the event further.
true
});
@@ -215,9 +216,7 @@ impl<'a> AuthWindow<'a> {
if let Some(auth_result) = auth_result_rx.recv().await {
match auth_result {
Ok(auth_data) => return Ok(auth_data),
Err(AuthDataError::TlsError) => {
return Err(anyhow::anyhow!("TLS error: certificate verify failed"))
}
Err(AuthDataError::TlsError) => bail!("TLS error: certificate verify failed"),
Err(AuthDataError::NotFound) => {
info!("No auth data found, it may not be the /SAML20/SP/ACS endpoint");
@@ -226,10 +225,7 @@ impl<'a> AuthWindow<'a> {
let window = Arc::clone(window);
let cancel_token = CancellationToken::new();
raise_window_cancel_token
.write()
.await
.replace(cancel_token.clone());
raise_window_cancel_token.write().await.replace(cancel_token.clone());
tokio::spawn(async move {
let delay_secs = 1;
@@ -283,12 +279,10 @@ fn raise_window(window: &Arc<Window>) {
}
}
pub(crate) async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> {
info!("Portal prelogin...");
pub async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> {
match prelogin(portal, gp_params).await? {
Prelogin::Saml(prelogin) => Ok(prelogin.saml_request().to_string()),
Prelogin::Standard(_) => Err(anyhow::anyhow!("Received non-SAML prelogin response")),
Prelogin::Standard(_) => bail!("Received non-SAML prelogin response"),
}
}

View File

@@ -13,18 +13,15 @@ use tempfile::NamedTempFile;
use crate::auth_window::{portal_prelogin, AuthWindow};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
compile_time::date_str!(),
")"
);
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
#[derive(Parser, Clone)]
#[command(version = VERSION)]
struct Cli {
server: String,
#[arg(long)]
gateway: bool,
#[arg(long)]
saml_request: Option<String>,
#[arg(long, default_value = GP_USER_AGENT)]
user_agent: String,
@@ -102,6 +99,7 @@ impl Cli {
.client_os(ClientOs::from(&self.os))
.os_version(self.os_version.clone())
.ignore_tls_errors(self.ignore_tls_errors)
.is_gateway(self.gateway)
.build();
gp_params

View File

@@ -9,12 +9,7 @@ use crate::{
launch_gui::{LaunchGuiArgs, LaunchGuiHandler},
};
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
compile_time::date_str!(),
")"
);
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
pub(crate) struct SharedArgs {
pub(crate) fix_openssl: bool,
@@ -53,10 +48,7 @@ struct Cli {
#[command(subcommand)]
command: CliCommand,
#[arg(
long,
help = "Get around the OpenSSL `unsafe legacy renegotiation` error"
)]
#[arg(long, help = "Get around the OpenSSL `unsafe legacy renegotiation` error")]
fix_openssl: bool,
#[arg(long, help = "Ignore the TLS errors")]
ignore_tls_errors: bool,
@@ -115,10 +107,8 @@ pub(crate) async fn run() {
eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" "));
}
if err.contains("certificate verify failed") {
eprintln!(
"\nRe-run it with the `--ignore-tls-errors` option to ignore the certificate error, e.g.:\n"
);
if err.contains("certificate verify failed") && !cli.ignore_tls_errors {
eprintln!("\nRe-run it with the `--ignore-tls-errors` option to ignore the certificate error, e.g.:\n");
// Print the command
let args = std::env::args().collect::<Vec<_>>();
eprintln!("{} --ignore-tls-errors {}\n", args[0], args[1..].join(" "));

View File

@@ -6,9 +6,12 @@ use gpapi::{
credential::{Credential, PasswordCredential},
gateway::gateway_login,
gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, Prelogin},
process::auth_launcher::SamlAuthLauncher,
utils::{self, shutdown_signal},
portal::{prelogin, retrieve_config, PortalError, Prelogin},
process::{
auth_launcher::SamlAuthLauncher,
users::{get_non_root_user, get_user_by_name},
},
utils::shutdown_signal,
GP_USER_AGENT,
};
use inquire::{Password, PasswordDisplayMode, Select, Text};
@@ -21,20 +24,19 @@ use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE};
pub(crate) struct ConnectArgs {
#[arg(help = "The portal server to connect to")]
server: String,
#[arg(
short,
long,
help = "The gateway to connect to, it will prompt if not specified"
)]
#[arg(short, long, help = "The gateway to connect to, it will prompt if not specified")]
gateway: Option<String>,
#[arg(
short,
long,
help = "The username to use, it will prompt if not specified"
)]
#[arg(short, long, help = "The username to use, it will prompt if not specified")]
user: Option<String>,
#[arg(long, short, help = "The VPNC script to use")]
script: Option<String>,
#[arg(long, help = "Same as the '--csd-user' option in the openconnect command")]
csd_user: Option<String>,
#[arg(long, help = "Same as the '--csd-wrapper' option in the openconnect command")]
csd_wrapper: Option<String>,
#[arg(long, default_value = GP_USER_AGENT, help = "The user agent to use")]
user_agent: String,
#[arg(long, default_value = "Linux")]
@@ -71,19 +73,38 @@ impl<'a> ConnectHandler<'a> {
Self { args, shared_args }
}
pub(crate) async fn handle(&self) -> anyhow::Result<()> {
let portal = utils::normalize_server(self.args.server.as_str())?;
let gp_params = GpParams::builder()
fn build_gp_params(&self) -> GpParams {
GpParams::builder()
.user_agent(&self.args.user_agent)
.client_os(ClientOs::from(&self.args.os))
.os_version(self.args.os_version())
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.build();
.build()
}
let prelogin = prelogin(&portal, &gp_params).await?;
let portal_credential = self.obtain_portal_credential(&prelogin).await?;
let mut portal_config = retrieve_config(&portal, &portal_credential, &gp_params).await?;
pub(crate) async fn handle(&self) -> anyhow::Result<()> {
let server = self.args.server.as_str();
let Err(err) = self.connect_portal_with_prelogin(server).await else {
return Ok(());
};
info!("Failed to connect portal with prelogin: {}", err);
if err.root_cause().downcast_ref::<PortalError>().is_some() {
info!("Trying the gateway authentication workflow...");
return self.connect_gateway_with_prelogin(server).await;
}
Err(err)
}
async fn connect_portal_with_prelogin(&self, portal: &str) -> anyhow::Result<()> {
let gp_params = self.build_gp_params();
let prelogin = prelogin(portal, &gp_params).await?;
let cred = self.obtain_credential(&prelogin, portal).await?;
let mut portal_config = retrieve_config(portal, &cred, &gp_params).await?;
let selected_gateway = match &self.args.gateway {
Some(gateway) => portal_config
@@ -105,11 +126,38 @@ impl<'a> ConnectHandler<'a> {
let gateway = selected_gateway.server();
let cred = portal_config.auth_cookie().into();
let token = gateway_login(gateway, &cred, &gp_params).await?;
let vpn = Vpn::builder(gateway, &token)
let cookie = match gateway_login(gateway, &cred, &gp_params).await {
Ok(cookie) => cookie,
Err(err) => {
info!("Gateway login failed: {}", err);
return self.connect_gateway_with_prelogin(gateway).await;
}
};
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> {
let mut gp_params = self.build_gp_params();
gp_params.set_is_gateway(true);
let prelogin = prelogin(gateway, &gp_params).await?;
let cred = self.obtain_credential(&prelogin, gateway).await?;
let cookie = gateway_login(gateway, &cred, &gp_params).await?;
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway(&self, gateway: &str, cookie: &str) -> anyhow::Result<()> {
let csd_uid = get_csd_uid(&self.args.csd_user)?;
let vpn = Vpn::builder(gateway, cookie)
.user_agent(self.args.user_agent.clone())
.script(self.args.script.clone())
.csd_uid(csd_uid)
.csd_wrapper(self.args.csd_wrapper.clone())
.build();
let vpn = Arc::new(vpn);
@@ -132,10 +180,13 @@ impl<'a> ConnectHandler<'a> {
Ok(())
}
async fn obtain_portal_credential(&self, prelogin: &Prelogin) -> anyhow::Result<Credential> {
async fn obtain_credential(&self, prelogin: &Prelogin, server: &str) -> anyhow::Result<Credential> {
let is_gateway = prelogin.is_gateway();
match prelogin {
Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server)
.gateway(is_gateway)
.saml_request(prelogin.saml_request())
.user_agent(&self.args.user_agent)
.os(self.args.os.as_str())
@@ -148,7 +199,8 @@ impl<'a> ConnectHandler<'a> {
.await
}
Prelogin::Standard(prelogin) => {
println!("{}", prelogin.auth_message());
let prefix = if is_gateway { "Gateway" } else { "Portal" };
println!("{} ({}: {})", prelogin.auth_message(), prefix, server);
let user = self.args.user.as_ref().map_or_else(
|| Text::new(&format!("{}:", prelogin.label_username())).prompt(),
@@ -173,3 +225,11 @@ fn write_pid_file() {
fs::write(GP_CLIENT_LOCK_FILE, pid.to_string()).unwrap();
info!("Wrote PID {} to {}", pid, GP_CLIENT_LOCK_FILE);
}
fn get_csd_uid(csd_user: &Option<String>) -> anyhow::Result<u32> {
if let Some(csd_user) = csd_user {
get_user_by_name(csd_user).map(|user| user.uid())
} else {
get_non_root_user().map_or_else(|_| Ok(0), |user| Ok(user.uid()))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,11 +32,13 @@ impl VpnTaskContext {
}
let info = req.info().clone();
let vpn_handle = self.vpn_handle.clone();
let vpn_handle = Arc::clone(&self.vpn_handle);
let args = req.args();
let vpn = Vpn::builder(req.gateway().server(), args.cookie())
.user_agent(args.user_agent())
.script(args.vpnc_script())
.csd_uid(args.csd_uid())
.csd_wrapper(args.csd_wrapper())
.os(args.openconnect_os())
.build();
@@ -73,7 +75,9 @@ impl VpnTaskContext {
pub async fn disconnect(&self) {
if let Some(disconnect_rx) = self.disconnect_rx.write().await.take() {
info!("Disconnecting VPN...");
if let Some(vpn) = self.vpn_handle.read().await.as_ref() {
info!("VPN is connected, start disconnecting...");
self.vpn_state_tx.send(VpnState::Disconnecting).ok();
vpn.disconnect()
}

View File

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

View File

@@ -25,10 +25,14 @@ url.workspace = true
regex.workspace = true
dotenvy_macro.workspace = true
uzers.workspace = true
serde_urlencoded.workspace = true
md5.workspace = true
tauri = { workspace = true, optional = true }
clap = { workspace = true, optional = true }
open = { version = "5", optional = true }
[features]
tauri = ["dep:tauri"]
clap = ["dep:clap"]
browser-auth = ["dep:open"]

View File

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

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::auth::SamlAuthData;
use crate::{auth::SamlAuthData, utils::base64::decode_to_string};
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
@@ -112,11 +112,7 @@ pub struct CachedCredential {
}
impl CachedCredential {
pub fn new(
username: String,
password: Option<String>,
auth_cookie: AuthCookieCredential,
) -> Self {
pub fn new(username: String, password: Option<String>, auth_cookie: AuthCookieCredential) -> Self {
Self {
username,
password,
@@ -139,6 +135,24 @@ impl CachedCredential {
pub fn set_auth_cookie(&mut self, auth_cookie: AuthCookieCredential) {
self.auth_cookie = auth_cookie;
}
pub fn set_username(&mut self, username: String) {
self.username = username;
}
pub fn set_password(&mut self, password: Option<String>) {
self.password = password.map(|s| s.to_string());
}
}
impl From<PasswordCredential> for CachedCredential {
fn from(value: PasswordCredential) -> Self {
Self::new(
value.username().to_owned(),
Some(value.password().to_owned()),
AuthCookieCredential::new("", "", ""),
)
}
}
#[derive(Debug, Serialize, Deserialize, Type, Clone)]
@@ -151,6 +165,17 @@ pub enum Credential {
}
impl Credential {
/// Create a credential from a globalprotectcallback:<base64 encoded string>
pub fn parse_gpcallback(auth_data: &str) -> anyhow::Result<Self> {
// Remove the surrounding quotes
let auth_data = auth_data.trim_matches('"');
let auth_data = auth_data.trim_start_matches("globalprotectcallback:");
let auth_data = decode_to_string(auth_data)?;
let auth_data = SamlAuthData::parse_html(&auth_data)?;
Self::try_from(auth_data)
}
pub fn username(&self) -> &str {
match self {
Credential::Password(cred) => cred.username(),
@@ -164,8 +189,7 @@ impl Credential {
let mut params = HashMap::new();
params.insert("user", self.username());
let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie) = match self
{
let (passwd, prelogin_cookie, portal_userauthcookie, portal_prelogonuserauthcookie) = match self {
Credential::Password(cred) => (Some(cred.password()), None, None, None),
Credential::PreloginCookie(cred) => (None, Some(cred.prelogin_cookie()), None, None),
Credential::AuthCookie(cred) => (
@@ -184,10 +208,7 @@ impl Credential {
params.insert("passwd", passwd.unwrap_or_default());
params.insert("prelogin-cookie", prelogin_cookie.unwrap_or_default());
params.insert(
"portal-userauthcookie",
portal_userauthcookie.unwrap_or_default(),
);
params.insert("portal-userauthcookie", portal_userauthcookie.unwrap_or_default());
params.insert(
"portal-prelogonuserauthcookie",
portal_prelogonuserauthcookie.unwrap_or_default(),

View File

@@ -0,0 +1,178 @@
use std::collections::HashMap;
use log::{info, warn};
use reqwest::Client;
use roxmltree::Document;
use crate::{gp_params::GpParams, process::hip_launcher::HipLauncher, utils::normalize_server};
struct HipReporter<'a> {
server: String,
cookie: &'a str,
md5: &'a str,
csd_wrapper: &'a str,
gp_params: &'a GpParams,
client: Client,
}
impl HipReporter<'_> {
async fn report(&self) -> anyhow::Result<()> {
let client_ip = self.retrieve_client_ip().await?;
let hip_needed = match self.check_hip(&client_ip).await {
Ok(hip_needed) => hip_needed,
Err(err) => {
warn!("Failed to check HIP: {}", err);
return Ok(());
}
};
if !hip_needed {
info!("HIP report not needed");
return Ok(());
}
info!("HIP report needed, generating report...");
let report = self.generate_report(&client_ip).await?;
if let Err(err) = self.submit_hip(&client_ip, &report).await {
warn!("Failed to submit HIP report: {}", err);
}
Ok(())
}
async fn retrieve_client_ip(&self) -> anyhow::Result<String> {
let config_url = format!("{}/ssl-vpn/getconfig.esp", self.server);
let mut params: HashMap<&str, &str> = HashMap::new();
params.insert("client-type", "1");
params.insert("protocol-version", "p1");
params.insert("internal", "no");
params.insert("ipv6-support", "yes");
params.insert("clientos", self.gp_params.client_os());
params.insert("hmac-algo", "sha1,md5,sha256");
params.insert("enc-algo", "aes-128-cbc,aes-256-cbc");
if let Some(os_version) = self.gp_params.os_version() {
params.insert("os-version", os_version);
}
if let Some(client_version) = self.gp_params.client_version() {
params.insert("app-version", client_version);
}
let params = merge_cookie_params(self.cookie, &params)?;
let res = self.client.post(&config_url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
let doc = Document::parse(&res_xml)?;
// Get <ip-address>
let ip = doc
.descendants()
.find(|n| n.has_tag_name("ip-address"))
.and_then(|n| n.text())
.ok_or_else(|| anyhow::anyhow!("ip-address not found"))?;
Ok(ip.to_string())
}
async fn check_hip(&self, client_ip: &str) -> anyhow::Result<bool> {
let url = format!("{}/ssl-vpn/hipreportcheck.esp", self.server);
let mut params = HashMap::new();
params.insert("client-role", "global-protect-full");
params.insert("client-ip", client_ip);
params.insert("md5", self.md5);
let params = merge_cookie_params(self.cookie, &params)?;
let res = self.client.post(&url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
is_hip_needed(&res_xml)
}
async fn generate_report(&self, client_ip: &str) -> anyhow::Result<String> {
let launcher = HipLauncher::new(self.csd_wrapper)
.cookie(self.cookie)
.md5(self.md5)
.client_ip(client_ip)
.client_os(self.gp_params.client_os())
.client_version(self.gp_params.client_version());
launcher.launch().await
}
async fn submit_hip(&self, client_ip: &str, report: &str) -> anyhow::Result<()> {
let url = format!("{}/ssl-vpn/hipreport.esp", self.server);
let mut params = HashMap::new();
params.insert("client-role", "global-protect-full");
params.insert("client-ip", client_ip);
params.insert("report", report);
let params = merge_cookie_params(self.cookie, &params)?;
let res = self.client.post(&url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
info!("HIP check response: {}", res_xml);
Ok(())
}
}
fn is_hip_needed(res_xml: &str) -> anyhow::Result<bool> {
let doc = Document::parse(res_xml)?;
let hip_needed = doc
.descendants()
.find(|n| n.has_tag_name("hip-report-needed"))
.and_then(|n| n.text())
.ok_or_else(|| anyhow::anyhow!("hip-report-needed not found"))?;
Ok(hip_needed == "yes")
}
fn merge_cookie_params(cookie: &str, params: &HashMap<&str, &str>) -> anyhow::Result<HashMap<String, String>> {
let cookie_params = serde_urlencoded::from_str::<HashMap<String, String>>(cookie)?;
let params = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.chain(cookie_params)
.collect::<HashMap<String, String>>();
Ok(params)
}
// Compute md5 for fields except authcookie,preferred-ip,preferred-ipv6
fn build_csd_token(cookie: &str) -> anyhow::Result<String> {
let mut cookie_params = serde_urlencoded::from_str::<Vec<(String, String)>>(cookie)?;
cookie_params.retain(|(k, _)| k != "authcookie" && k != "preferred-ip" && k != "preferred-ipv6");
let token = serde_urlencoded::to_string(cookie_params)?;
let md5 = format!("{:x}", md5::compute(token));
Ok(md5)
}
pub async fn hip_report(gateway: &str, cookie: &str, csd_wrapper: &str, gp_params: &GpParams) -> anyhow::Result<()> {
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let md5 = build_csd_token(cookie)?;
info!("Submit HIP report md5: {}", md5);
let reporter = HipReporter {
server: normalize_server(gateway)?,
cookie,
md5: &md5,
csd_wrapper,
gp_params,
client,
};
reporter.report().await
}

View File

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

View File

@@ -1,5 +1,6 @@
mod login;
mod parse_gateways;
pub mod hip;
pub use login::*;
pub(crate) use parse_gateways::*;
@@ -31,6 +32,15 @@ impl Display for Gateway {
}
impl Gateway {
pub fn new(name: String, address: String) -> Self {
Self {
name,
address,
priority: 0,
priority_rules: vec![],
}
}
pub fn name(&self) -> &str {
&self.name
}

View File

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

View File

@@ -44,12 +44,14 @@ impl ClientOs {
#[derive(Debug, Serialize, Deserialize, Type, Default)]
pub struct GpParams {
is_gateway: bool,
user_agent: String,
client_os: ClientOs,
os_version: Option<String>,
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
}
impl GpParams {
@@ -57,6 +59,14 @@ impl GpParams {
GpParamsBuilder::new()
}
pub(crate) fn is_gateway(&self) -> bool {
self.is_gateway
}
pub fn set_is_gateway(&mut self, is_gateway: bool) {
self.is_gateway = is_gateway;
}
pub(crate) fn user_agent(&self) -> &str {
&self.user_agent
}
@@ -69,6 +79,22 @@ impl GpParams {
self.ignore_tls_errors
}
pub fn prefer_default_browser(&self) -> bool {
self.prefer_default_browser
}
pub fn client_os(&self) -> &str {
self.client_os.as_str()
}
pub fn os_version(&self) -> Option<&str> {
self.os_version.as_deref()
}
pub fn client_version(&self) -> Option<&str> {
self.client_version.as_deref()
}
pub(crate) fn to_params(&self) -> HashMap<&str, &str> {
let mut params: HashMap<&str, &str> = HashMap::new();
let client_os = self.client_os.as_str();
@@ -88,35 +114,45 @@ impl GpParams {
params.insert("os-version", os_version);
}
if let Some(client_version) = &self.client_version {
params.insert("clientgpversion", client_version);
}
// NOTE: Do not include clientgpversion for now
// if let Some(client_version) = &self.client_version {
// params.insert("clientgpversion", client_version);
// }
params
}
}
pub struct GpParamsBuilder {
is_gateway: bool,
user_agent: String,
client_os: ClientOs,
os_version: Option<String>,
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
prefer_default_browser: bool,
}
impl GpParamsBuilder {
pub fn new() -> Self {
Self {
is_gateway: false,
user_agent: GP_USER_AGENT.to_string(),
client_os: ClientOs::Linux,
os_version: Default::default(),
client_version: Default::default(),
computer: whoami::hostname(),
ignore_tls_errors: false,
prefer_default_browser: false,
}
}
pub fn is_gateway(&mut self, is_gateway: bool) -> &mut Self {
self.is_gateway = is_gateway;
self
}
pub fn user_agent(&mut self, user_agent: &str) -> &mut Self {
self.user_agent = user_agent.to_string();
self
@@ -147,14 +183,21 @@ impl GpParamsBuilder {
self
}
pub fn prefer_default_browser(&mut self, prefer_default_browser: bool) -> &mut Self {
self.prefer_default_browser = prefer_default_browser;
self
}
pub fn build(&self) -> GpParams {
GpParams {
is_gateway: self.is_gateway,
user_agent: self.user_agent.clone(),
client_os: self.client_os.clone(),
os_version: self.os_version.clone(),
client_version: self.client_version.clone(),
computer: self.computer.clone(),
ignore_tls_errors: self.ignore_tls_errors,
prefer_default_browser: self.prefer_default_browser,
}
}
}

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
use anyhow::bail;
use log::{info, trace};
use reqwest::Client;
use log::info;
use reqwest::{Client, StatusCode};
use roxmltree::Document;
use serde::Serialize;
use specta::Type;
use crate::{
gp_params::GpParams,
portal::PortalError,
utils::{base64, normalize_server, xml},
};
@@ -25,7 +26,9 @@ const REQUIRED_PARAMS: [&str; 8] = [
#[serde(rename_all = "camelCase")]
pub struct SamlPrelogin {
region: String,
is_gateway: bool,
saml_request: String,
support_default_browser: bool,
}
impl SamlPrelogin {
@@ -36,12 +39,17 @@ impl SamlPrelogin {
pub fn saml_request(&self) -> &str {
&self.saml_request
}
pub fn support_default_browser(&self) -> bool {
self.support_default_browser
}
}
#[derive(Debug, Serialize, Type, Clone)]
#[serde(rename_all = "camelCase")]
pub struct StandardPrelogin {
region: String,
is_gateway: bool,
auth_message: String,
label_username: String,
label_password: String,
@@ -79,27 +87,31 @@ impl Prelogin {
Prelogin::Standard(standard) => standard.region(),
}
}
pub fn is_gateway(&self) -> bool {
match self {
Prelogin::Saml(saml) => saml.is_gateway,
Prelogin::Standard(standard) => standard.is_gateway,
}
}
}
pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> {
let user_agent = gp_params.user_agent();
info!("Portal prelogin, user_agent: {}", user_agent);
info!("Prelogin with user_agent: {}", user_agent);
let portal = normalize_server(portal)?;
let prelogin_url = format!(
"{}/global-protect/prelogin.esp?kerberos-support=yes",
portal
);
let is_gateway = gp_params.is_gateway();
let path = if is_gateway { "ssl-vpn" } else { "global-protect" };
let prelogin_url = format!("{portal}/{}/prelogin.esp", path);
let mut params = gp_params.to_params();
params.insert("tmp", "tmp");
params.insert("default-browser", "0");
params.insert("cas-support", "yes");
params.retain(|k, _| {
REQUIRED_PARAMS
.iter()
.any(|required_param| required_param == k)
});
params.insert("tmp", "tmp");
if gp_params.prefer_default_browser() {
params.insert("default-browser", "1");
}
params.retain(|k, _| REQUIRED_PARAMS.iter().any(|required_param| required_param == k));
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
@@ -107,9 +119,27 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
.build()?;
let res = client.post(&prelogin_url).form(&params).send().await?;
let res_xml = res.error_for_status()?.text().await?;
let status = res.status();
trace!("Prelogin response: {}", res_xml);
if status == StatusCode::NOT_FOUND {
bail!(PortalError::PreloginError("Prelogin endpoint not found".to_string()))
}
if status.is_client_error() || status.is_server_error() {
bail!("Prelogin error: {}", status)
}
let res_xml = res
.text()
.await
.map_err(|e| PortalError::PreloginError(e.to_string()))?;
let prelogin = parse_res_xml(res_xml, is_gateway).map_err(|e| PortalError::PreloginError(e.to_string()))?;
Ok(prelogin)
}
fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(&res_xml)?;
let status = xml::get_child_text(&doc, "status")
@@ -120,17 +150,24 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
bail!("Prelogin failed: {}", msg)
}
let region = xml::get_child_text(&doc, "region")
.ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain region element"))?;
let region = xml::get_child_text(&doc, "region").unwrap_or_else(|| {
info!("Prelogin response does not contain region element");
String::from("Unknown")
});
let saml_method = xml::get_child_text(&doc, "saml-auth-method");
let saml_request = xml::get_child_text(&doc, "saml-request");
let saml_default_browser = xml::get_child_text(&doc, "saml-default-browser");
// Check if the prelogin response is SAML
if saml_method.is_some() && saml_request.is_some() {
let saml_request = base64::decode_to_string(&saml_request.unwrap())?;
let support_default_browser = saml_default_browser.map(|s| s.to_lowercase() == "yes").unwrap_or(false);
let saml_prelogin = SamlPrelogin {
region,
is_gateway,
saml_request,
support_default_browser,
};
return Ok(Prelogin::Saml(saml_prelogin));
@@ -140,10 +177,11 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
let label_password = xml::get_child_text(&doc, "password-label");
// Check if the prelogin response is standard login
if label_username.is_some() && label_password.is_some() {
let auth_message = xml::get_child_text(&doc, "authentication-message")
.unwrap_or(String::from("Please enter the login credentials"));
let auth_message =
xml::get_child_text(&doc, "authentication-message").unwrap_or(String::from("Please enter the login credentials"));
let standard_prelogin = StandardPrelogin {
region,
is_gateway,
auth_message,
label_username: label_username.unwrap(),
label_password: label_password.unwrap(),

View File

@@ -1,5 +1,6 @@
use std::process::Stdio;
use anyhow::bail;
use tokio::process::Command;
use crate::{auth::SamlAuthResult, credential::Credential, GP_AUTH_BINARY};
@@ -8,6 +9,7 @@ use super::command_traits::CommandExt;
pub struct SamlAuthLauncher<'a> {
server: &'a str,
gateway: bool,
saml_request: Option<&'a str>,
user_agent: Option<&'a str>,
os: Option<&'a str>,
@@ -22,6 +24,7 @@ impl<'a> SamlAuthLauncher<'a> {
pub fn new(server: &'a str) -> Self {
Self {
server,
gateway: false,
saml_request: None,
user_agent: None,
os: None,
@@ -33,6 +36,11 @@ impl<'a> SamlAuthLauncher<'a> {
}
}
pub fn gateway(mut self, gateway: bool) -> Self {
self.gateway = gateway;
self
}
pub fn saml_request(mut self, saml_request: &'a str) -> Self {
self.saml_request = Some(saml_request);
self
@@ -78,6 +86,10 @@ impl<'a> SamlAuthLauncher<'a> {
let mut auth_cmd = Command::new(GP_AUTH_BINARY);
auth_cmd.arg(self.server);
if self.gateway {
auth_cmd.arg("--gateway");
}
if let Some(saml_request) = self.saml_request {
auth_cmd.arg("--saml-request").arg(saml_request);
}
@@ -118,12 +130,13 @@ impl<'a> SamlAuthLauncher<'a> {
.wait_with_output()
.await?;
let auth_result: SamlAuthResult = serde_json::from_slice(&output.stdout)
.map_err(|_| anyhow::anyhow!("Failed to parse auth data"))?;
let Ok(auth_result) = serde_json::from_slice::<SamlAuthResult>(&output.stdout) else {
bail!("Failed to parse auth data")
};
match auth_result {
SamlAuthResult::Success(auth_data) => Credential::try_from(auth_data),
SamlAuthResult::Failure(msg) => Err(anyhow::anyhow!(msg)),
SamlAuthResult::Failure(msg) => bail!(msg),
}
}
}

View File

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

View File

@@ -1,7 +1,8 @@
use anyhow::bail;
use std::{env, ffi::OsStr};
use std::ffi::OsStr;
use tokio::process::Command;
use uzers::{os::unix::UserExt, User};
use uzers::os::unix::UserExt;
use super::users::get_non_root_user;
pub trait CommandExt {
fn new_pkexec<S: AsRef<OsStr>>(program: S) -> Command;
@@ -21,8 +22,7 @@ impl CommandExt for Command {
}
fn into_non_root(mut self) -> anyhow::Result<Command> {
let user =
get_non_root_user().map_err(|_| anyhow::anyhow!("{:?} cannot be run as root", self))?;
let user = get_non_root_user().map_err(|_| anyhow::anyhow!("{:?} cannot be run as root", self))?;
self
.env("HOME", user.home_dir())
@@ -35,30 +35,3 @@ impl CommandExt for Command {
Ok(self)
}
}
fn get_non_root_user() -> anyhow::Result<User> {
let current_user = whoami::username();
let user = if current_user == "root" {
get_real_user()?
} else {
uzers::get_user_by_name(&current_user)
.ok_or_else(|| anyhow::anyhow!("User ({}) not found", current_user))?
};
if user.uid() == 0 {
bail!("Non-root user not found")
}
Ok(user)
}
fn get_real_user() -> anyhow::Result<User> {
// Read the UID from SUDO_UID or PKEXEC_UID environment variable if available.
let uid = match env::var("SUDO_UID") {
Ok(uid) => uid.parse::<u32>()?,
_ => env::var("PKEXEC_UID")?.parse::<u32>()?,
};
uzers::get_user_by_uid(uid).ok_or_else(|| anyhow::anyhow!("User not found"))
}

View File

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

View File

@@ -0,0 +1,94 @@
use std::process::Stdio;
use anyhow::bail;
use tokio::process::Command;
pub struct HipLauncher<'a> {
program: &'a str,
cookie: Option<&'a str>,
client_ip: Option<&'a str>,
md5: Option<&'a str>,
client_os: Option<&'a str>,
client_version: Option<&'a str>,
}
impl<'a> HipLauncher<'a> {
pub fn new(program: &'a str) -> Self {
Self {
program,
cookie: None,
client_ip: None,
md5: None,
client_os: None,
client_version: None,
}
}
pub fn cookie(mut self, cookie: &'a str) -> Self {
self.cookie = Some(cookie);
self
}
pub fn client_ip(mut self, client_ip: &'a str) -> Self {
self.client_ip = Some(client_ip);
self
}
pub fn md5(mut self, md5: &'a str) -> Self {
self.md5 = Some(md5);
self
}
pub fn client_os(mut self, client_os: &'a str) -> Self {
self.client_os = Some(client_os);
self
}
pub fn client_version(mut self, client_version: Option<&'a str>) -> Self {
self.client_version = client_version;
self
}
pub async fn launch(&self) -> anyhow::Result<String> {
let mut cmd = Command::new(self.program);
if let Some(cookie) = self.cookie {
cmd.arg("--cookie").arg(cookie);
}
if let Some(client_ip) = self.client_ip {
cmd.arg("--client-ip").arg(client_ip);
}
if let Some(md5) = self.md5 {
cmd.arg("--md5").arg(md5);
}
if let Some(client_os) = self.client_os {
cmd.arg("--client-os").arg(client_os);
}
if let Some(client_version) = self.client_version {
cmd.env("APP_VERSION", client_version);
}
let output = cmd
.kill_on_drop(true)
.stdout(Stdio::piped())
.spawn()?
.wait_with_output()
.await?;
if let Some(exit_status) = output.status.code() {
if exit_status != 0 {
bail!("HIP report generation failed with exit code {}", exit_status);
}
let report = String::from_utf8(output.stdout)?;
Ok(report)
} else {
bail!("HIP report generation failed");
}
}
}

View File

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

View File

@@ -0,0 +1,39 @@
use std::env;
use anyhow::bail;
use uzers::User;
pub fn get_user_by_name(username: &str) -> anyhow::Result<User> {
uzers::get_user_by_name(username).ok_or_else(|| anyhow::anyhow!("User ({}) not found", username))
}
pub fn get_non_root_user() -> anyhow::Result<User> {
let current_user = whoami::username();
let user = if current_user == "root" {
get_real_user()?
} else {
get_user_by_name(&current_user)?
};
if user.uid() == 0 {
bail!("Non-root user not found")
}
Ok(user)
}
pub fn get_current_user() -> anyhow::Result<User> {
let current_user = whoami::username();
get_user_by_name(&current_user)
}
fn get_real_user() -> anyhow::Result<User> {
// Read the UID from SUDO_UID or PKEXEC_UID environment variable if available.
let uid = match env::var("SUDO_UID") {
Ok(uid) => uid.parse::<u32>()?,
_ => env::var("PKEXEC_UID")?.parse::<u32>()?,
};
uzers::get_user_by_uid(uid).ok_or_else(|| anyhow::anyhow!("User not found"))
}

View File

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

View File

@@ -32,6 +32,8 @@ pub struct ConnectArgs {
cookie: String,
vpnc_script: Option<String>,
user_agent: Option<String>,
csd_uid: u32,
csd_wrapper: Option<String>,
os: Option<ClientOs>,
}
@@ -42,6 +44,8 @@ impl ConnectArgs {
vpnc_script: None,
user_agent: None,
os: None,
csd_uid: 0,
csd_wrapper: None,
}
}
@@ -58,10 +62,15 @@ impl ConnectArgs {
}
pub fn openconnect_os(&self) -> Option<String> {
self
.os
.as_ref()
.map(|os| os.to_openconnect_os().to_string())
self.os.as_ref().map(|os| os.to_openconnect_os().to_string())
}
pub fn csd_uid(&self) -> u32 {
self.csd_uid
}
pub fn csd_wrapper(&self) -> Option<String> {
self.csd_wrapper.clone()
}
}
@@ -84,6 +93,16 @@ impl ConnectRequest {
self
}
pub fn with_csd_uid(mut self, csd_uid: u32) -> Self {
self.args.csd_uid = csd_uid;
self
}
pub fn with_csd_wrapper<T: Into<Option<String>>>(mut self, csd_wrapper: T) -> Self {
self.args.csd_wrapper = csd_wrapper.into();
self
}
pub fn with_user_agent<T: Into<Option<String>>>(mut self, user_agent: T) -> Self {
self.args.user_agent = user_agent.into();
self

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,15 +15,15 @@ pub(crate) struct ConnectOptions {
pub os: *const c_char,
pub certificate: *const c_char,
pub servercert: *const c_char,
pub csd_uid: u32,
pub csd_wrapper: *const c_char,
}
#[link(name = "vpn")]
extern "C" {
#[link_name = "vpn_connect"]
fn vpn_connect(
options: *const ConnectOptions,
callback: extern "C" fn(i32, *mut c_void),
) -> c_int;
fn vpn_connect(options: *const ConnectOptions, callback: extern "C" fn(i32, *mut c_void)) -> c_int;
#[link_name = "vpn_disconnect"]
fn vpn_disconnect();

View File

@@ -61,6 +61,8 @@ int vpn_connect(const vpn_options *options, vpn_connected_callback callback)
INFO("User agent: %s", options->user_agent);
INFO("VPNC script: %s", options->script);
INFO("OS: %s", options->os);
INFO("CSD_USER: %d", options->csd_uid);
INFO("CSD_WRAPPER: %s", options->csd_wrapper);
vpninfo = openconnect_vpninfo_new(options->user_agent, validate_peer_cert, NULL, NULL, print_progress, NULL);
@@ -91,6 +93,10 @@ int vpn_connect(const vpn_options *options, vpn_connected_callback callback)
openconnect_set_system_trust(vpninfo, 0);
}
if (options->csd_wrapper) {
openconnect_setup_csd(vpninfo, options->csd_uid, 1, options->csd_wrapper);
}
g_cmd_pipe_fd = openconnect_setup_cmd_pipe(vpninfo);
if (g_cmd_pipe_fd < 0)
{
@@ -137,6 +143,9 @@ int vpn_connect(const vpn_options *options, vpn_connected_callback callback)
void vpn_disconnect()
{
char cmd = OC_CMD_CANCEL;
INFO("Stopping VPN connection: %d", g_cmd_pipe_fd);
if (write(g_cmd_pipe_fd, &cmd, 1) < 0)
{
ERROR("Failed to write to command pipe, VPN connection may not be stopped");

View File

@@ -16,6 +16,9 @@ typedef struct vpn_options
const char *os;
const char *certificate;
const char *servercert;
const uid_t csd_uid;
const char *csd_wrapper;
} vpn_options;
int vpn_connect(const vpn_options *options, vpn_connected_callback callback);

View File

@@ -18,6 +18,9 @@ pub struct Vpn {
certificate: Option<CString>,
servercert: Option<CString>,
csd_uid: u32,
csd_wrapper: Option<CString>,
callback: OnConnectedCallback,
}
@@ -27,11 +30,7 @@ impl Vpn {
}
pub fn connect(&self, on_connected: impl FnOnce() + 'static + Send + Sync) -> i32 {
self
.callback
.write()
.unwrap()
.replace(Box::new(on_connected));
self.callback.write().unwrap().replace(Box::new(on_connected));
let options = self.build_connect_options();
ffi::connect(&options)
@@ -60,6 +59,9 @@ impl Vpn {
os: self.os.as_ptr(),
certificate: Self::option_to_ptr(&self.certificate),
servercert: Self::option_to_ptr(&self.servercert),
csd_uid: self.csd_uid,
csd_wrapper: Self::option_to_ptr(&self.csd_wrapper),
}
}
@@ -77,6 +79,9 @@ pub struct VpnBuilder {
user_agent: Option<String>,
script: Option<String>,
os: Option<String>,
csd_uid: u32,
csd_wrapper: Option<String>,
}
impl VpnBuilder {
@@ -87,6 +92,8 @@ impl VpnBuilder {
user_agent: None,
script: None,
os: None,
csd_uid: 0,
csd_wrapper: None,
}
}
@@ -105,12 +112,19 @@ impl VpnBuilder {
self
}
pub fn csd_uid(mut self, csd_uid: u32) -> Self {
self.csd_uid = csd_uid;
self
}
pub fn csd_wrapper<T: Into<Option<String>>>(mut self, csd_wrapper: T) -> Self {
self.csd_wrapper = csd_wrapper.into();
self
}
pub fn build(self) -> Vpn {
let user_agent = self.user_agent.unwrap_or_default();
let script = self
.script
.or_else(find_default_vpnc_script)
.unwrap_or_default();
let script = self.script.or_else(find_default_vpnc_script).unwrap_or_default();
let os = self.os.unwrap_or("linux".to_string());
Vpn {
@@ -121,6 +135,10 @@ impl VpnBuilder {
os: Self::to_cstring(&os),
certificate: None,
servercert: None,
csd_uid: self.csd_uid,
csd_wrapper: self.csd_wrapper.as_deref().map(Self::to_cstring),
callback: Default::default(),
}
}

View File

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