Compare commits

...

8 Commits

Author SHA1 Message Date
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
19 changed files with 210 additions and 82 deletions

16
Cargo.lock generated
View File

@ -564,7 +564,7 @@ dependencies = [
[[package]]
name = "common"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"is_executable",
]
@ -1430,7 +1430,7 @@ dependencies = [
[[package]]
name = "gpapi"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"anyhow",
"base64 0.21.5",
@ -1439,7 +1439,6 @@ dependencies = [
"dotenvy_macro",
"log",
"md5",
"open",
"redact-engine",
"regex",
"reqwest",
@ -1462,7 +1461,7 @@ dependencies = [
[[package]]
name = "gpauth"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"anyhow",
"clap",
@ -1471,6 +1470,7 @@ dependencies = [
"gpapi",
"html-escape",
"log",
"open",
"regex",
"serde_json",
"tauri",
@ -1483,7 +1483,7 @@ dependencies = [
[[package]]
name = "gpclient"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"anyhow",
"clap",
@ -1505,7 +1505,7 @@ dependencies = [
[[package]]
name = "gpgui-helper"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"anyhow",
"clap",
@ -1523,7 +1523,7 @@ dependencies = [
[[package]]
name = "gpservice"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"anyhow",
"axum",
@ -2537,7 +2537,7 @@ dependencies = [
[[package]]
name = "openconnect"
version = "2.1.4"
version = "2.2.0"
dependencies = [
"cc",
"common",

View File

@ -5,7 +5,7 @@ members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth", "apps/g
[workspace.package]
rust-version = "1.70"
version = "2.1.4"
version = "2.2.0"
authors = ["Kevin Yue <k3vinyue@gmail.com>"]
homepage = "https://github.com/yuezk/GlobalProtect-openconnect"
edition = "2021"
@ -44,6 +44,7 @@ compile-time = "0.2"
serde_urlencoded = "0.7"
md5="0.7"
sha256="1"
open = "5"
# Tauri dependencies
tauri = { version = "1.5" }

View File

@ -55,7 +55,7 @@ The GUI version is also available after you installed it. You can launch it from
### 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
@ -68,12 +68,29 @@ sudo apt-get install globalprotect-openconnect
>
> For Linux Mint, you might need to import the GPG key with: `sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 7937C393082992E5D6E4A60453FC26B43838D761` if you encountered an error `gpg: keyserver receive failed: General error`.
#### Install from deb package
#### **Ubuntu 24.04**
Download the latest deb package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `dpkg`:
The `libwebkit2gtk-4.0-37` package was [removed](https://bugs.launchpad.net/ubuntu/+source/webkit2gtk/+bug/2061914) from its repo, before [the issue](https://github.com/yuezk/GlobalProtect-openconnect/issues/351) gets resolved, you need to install them manually:
```bash
sudo dpkg -i globalprotect-openconnect_*.deb
wget http://launchpadlibrarian.net/704701349/libwebkit2gtk-4.0-37_2.43.3-1_amd64.deb
wget http://launchpadlibrarian.net/704701345/libjavascriptcoregtk-4.0-18_2.43.3-1_amd64.deb
sudo dpkg --install *.deb
```
And the latest package is not available in the PPA, you can follow the [Install from deb package](#install-from-deb-package) section to install the latest package.
#### **Ubuntu 18.04**
The latest package is not available in the PPA either, but you still needs to add the `ppa:yuezk/globalprotect-openconnect` repo beforehand to use the required `openconnect` package. Then you can follow the [Install from deb package](#install-from-deb-package) section to install the latest package.
#### Install from deb package
Download the latest deb package from [releases](https://github.com/yuezk/GlobalProtect-openconnect/releases) page. Then install it with `apt`:
```bash
sudo apt install --fix-broken globalprotect-openconnect_*.deb
```
### Arch Linux / Manjaro
@ -151,6 +168,8 @@ You can also build the client from source, steps are as follows:
1. How to deal with error `Secure Storage not ready`
Try upgrade the client to `2.2.0` or later, which will use a file-based storage as a fallback.
You need to install the `gnome-keyring` package, and restart the system (See [#321](https://github.com/yuezk/GlobalProtect-openconnect/issues/321), [#316](https://github.com/yuezk/GlobalProtect-openconnect/issues/316)).
2. How to deal with error `(gpauth:18869): Gtk-WARNING **: 10:33:37.566: cannot open display:`

View File

@ -22,3 +22,4 @@ html-escape = "0.2.13"
webkit2gtk = "0.18.2"
tauri = { workspace = true, features = ["http-all"] }
compile-time.workspace = true
open.workspace = true

View File

@ -11,7 +11,10 @@ use serde_json::json;
use tauri::{App, AppHandle, RunEvent};
use tempfile::NamedTempFile;
use crate::auth_window::{portal_prelogin, AuthWindow};
use crate::{
auth_window::{portal_prelogin, AuthWindow},
browser_authenticator::BrowserAuthenticator,
};
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", compile_time::date_str!(), ")");
@ -37,6 +40,8 @@ struct Cli {
ignore_tls_errors: bool,
#[arg(long)]
clean: bool,
#[arg(long)]
default_browser: bool,
}
impl Cli {
@ -56,6 +61,15 @@ impl Cli {
None => portal_prelogin(&self.server, &gp_params).await?,
};
if self.default_browser {
let browser_auth = BrowserAuthenticator::new(&saml_request);
browser_auth.authenticate()?;
info!("Please continue the authentication process in the default browser");
return Ok(());
}
self.saml_request.replace(saml_request);
let app = create_app(self.clone())?;

View File

@ -1,6 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod auth_window;
mod browser_authenticator;
mod cli;
#[tokio::main]

View File

@ -19,8 +19,9 @@ use gpapi::{
use inquire::{Password, PasswordDisplayMode, Select, Text};
use log::info;
use openconnect::Vpn;
use tokio::{io::AsyncReadExt, net::TcpListener};
use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE};
use crate::{cli::SharedArgs, GP_CLIENT_LOCK_FILE, GP_CLIENT_PORT_FILE};
#[derive(Args)]
pub(crate) struct ConnectArgs {
@ -60,6 +61,8 @@ pub(crate) struct ConnectArgs {
hidpi: bool,
#[arg(long, help = "Do not reuse the remembered authentication cookie")]
clean: bool,
#[arg(long, help = "Use the default browser to authenticate")]
default_browser: bool,
}
impl ConnectArgs {
@ -240,7 +243,9 @@ impl<'a> ConnectHandler<'a> {
match prelogin {
Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server)
let use_default_browser = prelogin.support_default_browser() && self.args.default_browser;
let cred = SamlAuthLauncher::new(&self.args.server)
.gateway(is_gateway)
.saml_request(prelogin.saml_request())
.user_agent(&self.args.user_agent)
@ -250,8 +255,21 @@ impl<'a> ConnectHandler<'a> {
.fix_openssl(self.shared_args.fix_openssl)
.ignore_tls_errors(self.shared_args.ignore_tls_errors)
.clean(self.args.clean)
.default_browser(use_default_browser)
.launch()
.await
.await?;
if let Some(cred) = cred {
return Ok(cred);
}
if !use_default_browser {
// This should never happen
unreachable!("SAML authentication failed without using the default browser");
}
info!("Waiting for the browser authentication to complete...");
wait_credentials().await
}
Prelogin::Standard(prelogin) => {
let prefix = if is_gateway { "Gateway" } else { "Portal" };
@ -274,6 +292,27 @@ impl<'a> ConnectHandler<'a> {
}
}
async fn wait_credentials() -> anyhow::Result<Credential> {
// Start a local server to receive the browser authentication data
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
// Write the port to a file
fs::write(GP_CLIENT_PORT_FILE, port.to_string())?;
info!("Listening authentication data on port {}", port);
let (mut socket, _) = listener.accept().await?;
info!("Received the browser authentication data from the socket");
let mut data = String::new();
socket.read_to_string(&mut data).await?;
// Remove the port file
fs::remove_file(GP_CLIENT_PORT_FILE)?;
Credential::from_gpcallback(&data)
}
fn write_pid_file() {
let pid = std::process::id();

View File

@ -7,6 +7,9 @@ use gpapi::{
utils::{endpoint::http_endpoint, env_file, shutdown_signal},
};
use log::info;
use tokio::io::AsyncWriteExt;
use crate::GP_CLIENT_PORT_FILE;
#[derive(Args)]
pub(crate) struct LaunchGuiArgs {
@ -78,6 +81,11 @@ impl<'a> LaunchGuiHandler<'a> {
}
async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
let _ = tokio::join!(feed_auth_data_gui(auth_data), feed_auth_data_cli(auth_data));
Ok(())
}
async fn feed_auth_data_gui(auth_data: &str) -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;
reqwest::Client::default()
@ -90,6 +98,15 @@ async fn feed_auth_data(auth_data: &str) -> anyhow::Result<()> {
Ok(())
}
async fn feed_auth_data_cli(auth_data: &str) -> anyhow::Result<()> {
let port = tokio::fs::read_to_string(GP_CLIENT_PORT_FILE).await?;
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port.trim())).await?;
stream.write_all(auth_data.as_bytes()).await?;
Ok(())
}
async fn try_active_gui() -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?;

View File

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

View File

@ -1,5 +1,10 @@
# Changelog
## 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))

View File

@ -31,9 +31,7 @@ sha256.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

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

View File

@ -8,7 +8,7 @@ use crate::{
credential::Credential,
error::PortalError,
gp_params::GpParams,
utils::{normalize_server, parse_gp_error, remove_url_scheme},
utils::{normalize_server, parse_gp_response, remove_url_scheme},
};
pub enum GatewayLogin {
@ -41,20 +41,10 @@ pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParam
.await
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
let status = res.status();
if status.is_client_error() || status.is_server_error() {
let (reason, res) = parse_gp_error(res).await;
warn!(
"Gateway login error: reason={}, status={}, response={}",
reason, status, res
);
bail!("Gateway login error, reason: {}", reason);
}
let res = res.text().await?;
let res = parse_gp_response(res).await.map_err(|err| {
warn!("{err}");
anyhow::anyhow!("Gateway login error: {}", err.reason)
})?;
// MFA detected
if res.contains("Challenge") {

View File

@ -10,7 +10,7 @@ use crate::{
error::PortalError,
gateway::{parse_gateways, Gateway},
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)]
@ -108,24 +108,19 @@ pub async fn retrieve_config(portal: &str, cred: &Credential, gp_params: &GpPara
.send()
.await
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
let status = res.status();
if status == StatusCode::NOT_FOUND {
bail!(PortalError::ConfigError("Config endpoint not found".to_string()))
}
let res_xml = parse_gp_response(res).await.or_else(|err| {
if err.status == StatusCode::NOT_FOUND {
bail!(PortalError::ConfigError("Config endpoint not found".to_string()));
}
if status.is_client_error() || status.is_server_error() {
let (reason, res) = parse_gp_error(res).await;
if err.is_status_error() {
warn!("{err}");
bail!("Portal config error: {}", err.reason);
}
warn!(
"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()))?;
Err(anyhow::anyhow!(PortalError::ConfigError(err.reason)))
})?;
if res_xml.is_empty() {
bail!(PortalError::ConfigError("Empty portal config response".to_string()))

View File

@ -8,7 +8,7 @@ use specta::Type;
use crate::{
error::PortalError,
gp_params::GpParams,
utils::{base64, normalize_server, parse_gp_error, xml},
utils::{base64, normalize_server, parse_gp_response, xml},
};
const REQUIRED_PARAMS: [&str; 8] = [
@ -98,10 +98,12 @@ impl Prelogin {
pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prelogin> {
let user_agent = gp_params.user_agent();
info!("Prelogin with user_agent: {}", user_agent);
let is_gateway = gp_params.is_gateway();
let prelogin_type = if is_gateway { "Gateway" } else { "Portal" };
info!("{} prelogin with user_agent: {}", prelogin_type, user_agent);
let portal = normalize_server(portal)?;
let is_gateway = gp_params.is_gateway();
let path = if is_gateway { "ssl-vpn" } else { "global-protect" };
let prelogin_url = format!("{portal}/{}/prelogin.esp", path);
let mut params = gp_params.to_params();
@ -112,8 +114,6 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
params.retain(|k, _| REQUIRED_PARAMS.iter().any(|required_param| required_param == k));
info!("Prelogin with params: {:?}", params);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(user_agent)
@ -126,38 +126,36 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
.await
.map_err(|e| anyhow::anyhow!(PortalError::NetworkError(e.to_string())))?;
let status = res.status();
if status == StatusCode::NOT_FOUND {
bail!(PortalError::PreloginError("Prelogin endpoint not found".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 status.is_client_error() || status.is_server_error() {
let (reason, res) = parse_gp_error(res).await;
if err.is_status_error() {
warn!("{err}");
bail!("Prelogin error: {}", err.reason)
}
warn!("Prelogin error: reason={}, status={}, response={}", reason, status, res);
Err(anyhow!(PortalError::PreloginError(err.reason)))
})?;
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()))?;
let prelogin = parse_res_xml(&res_xml, is_gateway).map_err(|err| {
warn!("Parse response error, response: {}", res_xml);
PortalError::PreloginError(err.to_string())
})?;
Ok(prelogin)
}
fn parse_res_xml(res_xml: String, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(&res_xml)?;
fn parse_res_xml(res_xml: &str, is_gateway: bool) -> anyhow::Result<Prelogin> {
let doc = Document::parse(res_xml)?;
let status = xml::get_child_text(&doc, "status")
.ok_or_else(|| anyhow::anyhow!("Prelogin response does not contain status element"))?;
// Check the status of the prelogin response
if status.to_uppercase() != "SUCCESS" {
let msg = xml::get_child_text(&doc, "msg").unwrap_or(String::from("Unknown error"));
bail!("Prelogin failed: {}", msg)
bail!("{}", msg)
}
let region = xml::get_child_text(&doc, "region").unwrap_or_else(|| {

View File

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

View File

@ -2,8 +2,6 @@ pub(crate) mod command_traits;
pub(crate) mod gui_helper_launcher;
pub mod auth_launcher;
#[cfg(feature = "browser-auth")]
pub mod browser_authenticator;
pub mod gui_launcher;
pub mod hip_launcher;
pub mod service_launcher;

View File

@ -1,5 +1,3 @@
use reqwest::{Response, Url};
pub(crate) mod xml;
pub mod base64;
@ -15,8 +13,12 @@ pub mod window;
mod shutdown_signal;
use log::warn;
pub use shutdown_signal::shutdown_signal;
use reqwest::{Response, StatusCode, Url};
use thiserror::Error;
/// Normalize the server URL to the format `https://<host>:<port>`
pub fn normalize_server(server: &str) -> anyhow::Result<String> {
let server = if server.starts_with("https://") || server.starts_with("http://") {
@ -42,7 +44,41 @@ pub fn remove_url_scheme(s: &str) -> String {
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
.headers()
.get("x-private-pan-globalprotect")