Compare commits

..

7 Commits

Author SHA1 Message Date
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
27 changed files with 456 additions and 124 deletions

View File

@@ -11,8 +11,10 @@
"dotenvy", "dotenvy",
"getconfig", "getconfig",
"globalprotect", "globalprotect",
"globalprotectcallback",
"gpapi", "gpapi",
"gpauth", "gpauth",
"gpcallback",
"gpclient", "gpclient",
"gpcommon", "gpcommon",
"gpgui", "gpgui",
@@ -50,5 +52,6 @@
"wmctrl", "wmctrl",
"XAUTHORITY", "XAUTHORITY",
"yuezk" "yuezk"
] ],
"rust-analyzer.cargo.features": "all",
} }

47
Cargo.lock generated
View File

@@ -1423,7 +1423,7 @@ dependencies = [
[[package]] [[package]]
name = "gpapi" name = "gpapi"
version = "2.0.0-beta3" version = "2.0.0-beta7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.21.5", "base64 0.21.5",
@@ -1431,6 +1431,7 @@ dependencies = [
"clap", "clap",
"dotenvy_macro", "dotenvy_macro",
"log", "log",
"open",
"redact-engine", "redact-engine",
"regex", "regex",
"reqwest", "reqwest",
@@ -1451,7 +1452,7 @@ dependencies = [
[[package]] [[package]]
name = "gpauth" name = "gpauth"
version = "2.0.0-beta3" version = "2.0.0-beta7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -1471,7 +1472,7 @@ dependencies = [
[[package]] [[package]]
name = "gpclient" name = "gpclient"
version = "2.0.0-beta3" version = "2.0.0-beta7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -1492,7 +1493,7 @@ dependencies = [
[[package]] [[package]]
name = "gpservice" name = "gpservice"
version = "2.0.0-beta3" version = "2.0.0-beta7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1963,6 +1964,15 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" 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]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.10" version = "0.4.10"
@@ -1974,6 +1984,16 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "is_executable" name = "is_executable"
version = "1.0.1" version = "1.0.1"
@@ -2445,9 +2465,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" 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]] [[package]]
name = "openconnect" name = "openconnect"
version = "2.0.0-beta3" version = "2.0.0-beta7"
dependencies = [ dependencies = [
"cc", "cc",
"is_executable", "is_executable",
@@ -2574,6 +2605,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pathdiff"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"

View File

@@ -4,7 +4,7 @@ resolver = "2"
members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"] members = ["crates/*", "apps/gpclient", "apps/gpservice", "apps/gpauth"]
[workspace.package] [workspace.package]
version = "2.0.0-beta3" version = "2.0.0-beta7"
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"

View File

@@ -11,6 +11,8 @@ A GUI for GlobalProtect VPN, based on OpenConnect, supports the SSO authenticati
- [x] Better Linux support - [x] Better Linux support
- [x] Support both CLI and GUI - [x] Support both CLI and GUI
- [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 authentication using default browser
- [x] Support multiple portals - [x] Support multiple portals
- [x] Support gateway selection - [x] Support gateway selection
- [x] Support auto-connect on startup - [x] Support auto-connect on startup

View File

@@ -203,7 +203,8 @@ 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); 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 to stop other handlers from being invoked for the event. false to propagate the event further.
true true
}); });
@@ -283,12 +284,10 @@ fn raise_window(window: &Arc<Window>) {
} }
} }
pub(crate) async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> { pub async fn portal_prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<String> {
info!("Portal prelogin...");
match prelogin(portal, gp_params).await? { match prelogin(portal, gp_params).await? {
Prelogin::Saml(prelogin) => Ok(prelogin.saml_request().to_string()), 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

@@ -25,6 +25,8 @@ const VERSION: &str = concat!(
struct Cli { struct Cli {
server: String, server: String,
#[arg(long)] #[arg(long)]
gateway: bool,
#[arg(long)]
saml_request: Option<String>, saml_request: Option<String>,
#[arg(long, default_value = GP_USER_AGENT)] #[arg(long, default_value = GP_USER_AGENT)]
user_agent: String, user_agent: String,
@@ -102,6 +104,7 @@ impl Cli {
.client_os(ClientOs::from(&self.os)) .client_os(ClientOs::from(&self.os))
.os_version(self.os_version.clone()) .os_version(self.os_version.clone())
.ignore_tls_errors(self.ignore_tls_errors) .ignore_tls_errors(self.ignore_tls_errors)
.is_gateway(self.gateway)
.build(); .build();
gp_params gp_params

View File

@@ -115,10 +115,8 @@ pub(crate) async fn run() {
eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" ")); eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" "));
} }
if err.contains("certificate verify failed") { if err.contains("certificate verify failed") && !cli.ignore_tls_errors {
eprintln!( eprintln!("\nRe-run it with the `--ignore-tls-errors` option to ignore the certificate error, e.g.:\n");
"\nRe-run it with the `--ignore-tls-errors` option to ignore the certificate error, e.g.:\n"
);
// Print the command // Print the command
let args = std::env::args().collect::<Vec<_>>(); let args = std::env::args().collect::<Vec<_>>();
eprintln!("{} --ignore-tls-errors {}\n", args[0], args[1..].join(" ")); eprintln!("{} --ignore-tls-errors {}\n", args[0], args[1..].join(" "));

View File

@@ -6,9 +6,9 @@ use gpapi::{
credential::{Credential, PasswordCredential}, credential::{Credential, PasswordCredential},
gateway::gateway_login, gateway::gateway_login,
gp_params::{ClientOs, GpParams}, gp_params::{ClientOs, GpParams},
portal::{prelogin, retrieve_config, Prelogin}, portal::{prelogin, retrieve_config, PortalError, Prelogin},
process::auth_launcher::SamlAuthLauncher, process::auth_launcher::SamlAuthLauncher,
utils::{self, shutdown_signal}, utils::shutdown_signal,
GP_USER_AGENT, GP_USER_AGENT,
}; };
use inquire::{Password, PasswordDisplayMode, Select, Text}; use inquire::{Password, PasswordDisplayMode, Select, Text};
@@ -71,19 +71,38 @@ impl<'a> ConnectHandler<'a> {
Self { args, shared_args } Self { args, shared_args }
} }
pub(crate) async fn handle(&self) -> anyhow::Result<()> { fn build_gp_params(&self) -> GpParams {
let portal = utils::normalize_server(self.args.server.as_str())?; GpParams::builder()
let gp_params = GpParams::builder()
.user_agent(&self.args.user_agent) .user_agent(&self.args.user_agent)
.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)
.build(); .build()
}
let prelogin = prelogin(&portal, &gp_params).await?; pub(crate) async fn handle(&self) -> anyhow::Result<()> {
let portal_credential = self.obtain_portal_credential(&prelogin).await?; let server = self.args.server.as_str();
let mut portal_config = retrieve_config(&portal, &portal_credential, &gp_params).await?;
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 { let selected_gateway = match &self.args.gateway {
Some(gateway) => portal_config Some(gateway) => portal_config
@@ -105,9 +124,32 @@ 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 token = gateway_login(gateway, &cred, &gp_params).await?;
let vpn = Vpn::builder(gateway, &token) let cookie = match gateway_login(gateway, &cred, &gp_params).await {
Ok(cookie) => cookie,
Err(err) => {
info!("Gateway login failed: {}", err);
return self.connect_gateway_with_prelogin(gateway).await;
}
};
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway_with_prelogin(&self, gateway: &str) -> anyhow::Result<()> {
let mut gp_params = self.build_gp_params();
gp_params.set_is_gateway(true);
let prelogin = prelogin(gateway, &gp_params).await?;
let cred = self.obtain_credential(&prelogin, &gateway).await?;
let cookie = gateway_login(gateway, &cred, &gp_params).await?;
self.connect_gateway(gateway, &cookie).await
}
async fn connect_gateway(&self, gateway: &str, cookie: &str) -> anyhow::Result<()> {
let vpn = Vpn::builder(gateway, cookie)
.user_agent(self.args.user_agent.clone()) .user_agent(self.args.user_agent.clone())
.script(self.args.script.clone()) .script(self.args.script.clone())
.build(); .build();
@@ -132,10 +174,17 @@ impl<'a> ConnectHandler<'a> {
Ok(()) 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 { match prelogin {
Prelogin::Saml(prelogin) => { Prelogin::Saml(prelogin) => {
SamlAuthLauncher::new(&self.args.server) SamlAuthLauncher::new(&self.args.server)
.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)
.os(self.args.os.as_str()) .os(self.args.os.as_str())
@@ -148,7 +197,8 @@ impl<'a> ConnectHandler<'a> {
.await .await
} }
Prelogin::Standard(prelogin) => { 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( let user = self.args.user.as_ref().map_or_else(
|| Text::new(&format!("{}:", prelogin.label_username())).prompt(), || Text::new(&format!("{}:", prelogin.label_username())).prompt(),

View File

@@ -10,7 +10,12 @@ use log::info;
#[derive(Args)] #[derive(Args)]
pub(crate) struct LaunchGuiArgs { 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, minimized: bool,
} }
@@ -30,6 +35,12 @@ impl<'a> LaunchGuiHandler<'a> {
anyhow::bail!("`launch-gui` cannot be run as root"); 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() { if try_active_gui().await.is_ok() {
info!("The GUI is already running"); info!("The GUI is already running");
return Ok(()); 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<()> { async fn try_active_gui() -> anyhow::Result<()> {
let service_endpoint = http_endpoint().await?; 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

@@ -21,6 +21,13 @@ pub(crate) async fn active_gui(State(ctx): State<Arc<WsServerContext>>) -> impl
ctx.send_event(WsEvent::ActiveGui).await; ctx.send_event(WsEvent::ActiveGui).await;
} }
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( pub(crate) async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(ctx): State<Arc<WsServerContext>>, State(ctx): State<Arc<WsServerContext>>,

View File

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

View File

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

View File

@@ -28,7 +28,9 @@ uzers.workspace = true
tauri = { workspace = true, optional = true } tauri = { workspace = true, optional = true }
clap = { workspace = true, optional = true } clap = { workspace = true, optional = true }
open = { version = "5", optional = true }
[features] [features]
tauri = ["dep:tauri"] tauri = ["dep:tauri"]
clap = ["dep:clap"] clap = ["dep:clap"]
browser-auth = ["dep:open"]

View File

@@ -1,3 +1,5 @@
use anyhow::bail;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -37,6 +39,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 { pub fn username(&self) -> &str {
&self.username &self.username
} }
@@ -61,3 +89,10 @@ impl SamlAuthData {
username_valid && (prelogin_cookie_valid || portal_userauthcookie_valid) 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 serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use crate::auth::SamlAuthData; use crate::{auth::SamlAuthData, utils::base64::decode_to_string};
#[derive(Debug, Serialize, Deserialize, Type, Clone)] #[derive(Debug, Serialize, Deserialize, Type, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -139,6 +139,24 @@ impl CachedCredential {
pub fn set_auth_cookie(&mut self, auth_cookie: AuthCookieCredential) { pub fn set_auth_cookie(&mut self, auth_cookie: AuthCookieCredential) {
self.auth_cookie = auth_cookie; 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)] #[derive(Debug, Serialize, Deserialize, Type, Clone)]
@@ -151,6 +169,17 @@ pub enum Credential {
} }
impl 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 { pub fn username(&self) -> &str {
match self { match self {
Credential::Password(cred) => cred.username(), Credential::Password(cred) => cred.username(),

View File

@@ -1,17 +1,26 @@
use anyhow::bail;
use log::info; use log::info;
use reqwest::Client; use reqwest::Client;
use roxmltree::Document; use roxmltree::Document;
use urlencoding::encode; 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( pub async fn gateway_login(
gateway: &str, gateway: &str,
cred: &Credential, cred: &Credential,
gp_params: &GpParams, gp_params: &GpParams,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let login_url = format!("https://{}/ssl-vpn/login.esp", gateway); let url = normalize_server(gateway)?;
let gateway = remove_url_scheme(&url);
let login_url = format!("{}/ssl-vpn/login.esp", url);
let client = Client::builder() let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent()) .user_agent(gp_params.user_agent())
.build()?; .build()?;
@@ -19,13 +28,18 @@ pub async fn gateway_login(
let extra_params = gp_params.to_params(); let extra_params = gp_params.to_params();
params.extend(extra_params); params.extend(extra_params);
params.insert("server", gateway); params.insert("server", &gateway);
info!("Gateway login, user_agent: {}", gp_params.user_agent()); info!("Gateway login, user_agent: {}", gp_params.user_agent());
let res = client.post(&login_url).form(&params).send().await?; 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)?; let doc = Document::parse(&res_xml)?;
build_gateway_token(&doc, gp_params.computer()) build_gateway_token(&doc, gp_params.computer())

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
use anyhow::ensure; use anyhow::bail;
use log::info; use log::info;
use reqwest::Client; use reqwest::{Client, StatusCode};
use roxmltree::Document; use roxmltree::Document;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use thiserror::Error;
use crate::{ use crate::{
credential::{AuthCookieCredential, Credential}, credential::{AuthCookieCredential, Credential},
gateway::{parse_gateways, Gateway}, gateway::{parse_gateways, Gateway},
gp_params::GpParams, gp_params::GpParams,
utils::{normalize_server, xml}, portal::PortalError,
utils::{normalize_server, remove_url_scheme, xml},
}; };
#[derive(Debug, Serialize, Type)] #[derive(Debug, Serialize, Type)]
@@ -18,25 +18,12 @@ use crate::{
pub struct PortalConfig { pub struct PortalConfig {
portal: String, portal: String,
auth_cookie: AuthCookieCredential, auth_cookie: AuthCookieCredential,
config_cred: Credential,
gateways: Vec<Gateway>, gateways: Vec<Gateway>,
config_digest: Option<String>, config_digest: Option<String>,
} }
impl PortalConfig { 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 { pub fn portal(&self) -> &str {
&self.portal &self.portal
} }
@@ -49,6 +36,10 @@ impl PortalConfig {
&self.auth_cookie &self.auth_cookie
} }
pub fn config_cred(&self) -> &Credential {
&self.config_cred
}
/// In-place sort the gateways by region /// In-place sort the gateways by region
pub fn sort_gateways(&mut self, region: &str) { pub fn sort_gateways(&mut self, region: &str) {
let preferred_gateway = self.find_preferred_gateway(region); let preferred_gateway = self.find_preferred_gateway(region);
@@ -98,18 +89,6 @@ impl PortalConfig {
} }
} }
#[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( pub async fn retrieve_config(
portal: &str, portal: &str,
cred: &Credential, cred: &Credential,
@@ -120,6 +99,7 @@ pub async fn retrieve_config(
let url = format!("{}/global-protect/getconfig.esp", portal); let url = format!("{}/global-protect/getconfig.esp", portal);
let client = Client::builder() let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent()) .user_agent(gp_params.user_agent())
.build()?; .build()?;
@@ -133,42 +113,54 @@ pub async fn retrieve_config(
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.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)?; if status.is_client_error() || status.is_server_error() {
let gateways = parse_gateways(&doc).ok_or_else(|| anyhow::anyhow!("Failed to parse gateways"))?; 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 user_auth_cookie = xml::get_child_text(&doc, "portal-userauthcookie").unwrap_or_default();
let prelogon_user_auth_cookie = let prelogon_user_auth_cookie =
xml::get_child_text(&doc, "portal-prelogonuserauthcookie").unwrap_or_default(); xml::get_child_text(&doc, "portal-prelogonuserauthcookie").unwrap_or_default();
let config_digest = xml::get_child_text(&doc, "config-digest"); let config_digest = xml::get_child_text(&doc, "config-digest");
ensure!( if gateways.is_empty() {
!user_auth_cookie.is_empty() && !prelogon_user_auth_cookie.is_empty(), gateways.push(Gateway::new(server.to_string(), server.to_string()));
PortalConfigError::EmptyAuthCookie }
);
ensure!( Ok(PortalConfig {
user_auth_cookie != "empty" && prelogon_user_auth_cookie != "empty", portal: server.to_string(),
PortalConfigError::InvalidAuthCookie auth_cookie: AuthCookieCredential::new(
);
ensure!(!gateways.is_empty(), PortalConfigError::EmptyGateways);
Ok(PortalConfig::new(
server.to_string(),
AuthCookieCredential::new(
cred.username(), cred.username(),
&user_auth_cookie, &user_auth_cookie,
&prelogon_user_auth_cookie, &prelogon_user_auth_cookie,
), ),
config_cred: cred.clone(),
gateways, gateways,
config_digest, 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 config::*;
pub use prelogin::*; 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 anyhow::bail;
use log::{info, trace}; use log::info;
use reqwest::Client; use reqwest::{Client, StatusCode};
use roxmltree::Document; use roxmltree::Document;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use crate::{ use crate::{
gp_params::GpParams, gp_params::GpParams,
portal::PortalError,
utils::{base64, normalize_server, xml}, utils::{base64, normalize_server, xml},
}; };
@@ -25,7 +26,9 @@ const REQUIRED_PARAMS: [&str; 8] = [
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SamlPrelogin { pub struct SamlPrelogin {
region: String, region: String,
is_gateway: bool,
saml_request: String, saml_request: String,
support_default_browser: bool,
} }
impl SamlPrelogin { impl SamlPrelogin {
@@ -36,12 +39,17 @@ impl SamlPrelogin {
pub fn saml_request(&self) -> &str { pub fn saml_request(&self) -> &str {
&self.saml_request &self.saml_request
} }
pub fn support_default_browser(&self) -> bool {
self.support_default_browser
}
} }
#[derive(Debug, Serialize, Type, Clone)] #[derive(Debug, Serialize, Type, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StandardPrelogin { pub struct StandardPrelogin {
region: String, region: String,
is_gateway: bool,
auth_message: String, auth_message: String,
label_username: String, label_username: String,
label_password: String, label_password: String,
@@ -79,21 +87,33 @@ impl Prelogin {
Prelogin::Standard(standard) => standard.region(), 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> { 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!("Portal prelogin, user_agent: {}", user_agent); info!("Prelogin with user_agent: {}", user_agent);
let portal = normalize_server(portal)?; let portal = normalize_server(portal)?;
let prelogin_url = format!( let is_gateway = gp_params.is_gateway();
"{}/global-protect/prelogin.esp?kerberos-support=yes", let path = if is_gateway {
portal "ssl-vpn"
); } else {
"global-protect"
};
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");
params.insert("default-browser", "0"); if gp_params.prefer_default_browser() {
params.insert("cas-support", "yes"); params.insert("default-browser", "1");
}
params.retain(|k, _| { params.retain(|k, _| {
REQUIRED_PARAMS REQUIRED_PARAMS
@@ -107,9 +127,30 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
.build()?; .build()?;
let res = client.post(&prelogin_url).form(&params).send().await?; 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 doc = Document::parse(&res_xml)?;
let status = xml::get_child_text(&doc, "status") let status = xml::get_child_text(&doc, "status")
@@ -125,12 +166,19 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
let saml_method = xml::get_child_text(&doc, "saml-auth-method"); let saml_method = xml::get_child_text(&doc, "saml-auth-method");
let saml_request = xml::get_child_text(&doc, "saml-request"); 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 // Check if the prelogin response is SAML
if saml_method.is_some() && saml_request.is_some() { if saml_method.is_some() && saml_request.is_some() {
let saml_request = base64::decode_to_string(&saml_request.unwrap())?; 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 { let saml_prelogin = SamlPrelogin {
region, region,
is_gateway,
saml_request, saml_request,
support_default_browser,
}; };
return Ok(Prelogin::Saml(saml_prelogin)); return Ok(Prelogin::Saml(saml_prelogin));
@@ -144,6 +192,7 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
.unwrap_or(String::from("Please enter the login credentials")); .unwrap_or(String::from("Please enter the login credentials"));
let standard_prelogin = StandardPrelogin { let standard_prelogin = StandardPrelogin {
region, region,
is_gateway,
auth_message, auth_message,
label_username: label_username.unwrap(), label_username: label_username.unwrap(),
label_password: label_password.unwrap(), label_password: label_password.unwrap(),

View File

@@ -8,6 +8,7 @@ use super::command_traits::CommandExt;
pub struct SamlAuthLauncher<'a> { pub struct SamlAuthLauncher<'a> {
server: &'a str, server: &'a str,
gateway: bool,
saml_request: Option<&'a str>, saml_request: Option<&'a str>,
user_agent: Option<&'a str>, user_agent: Option<&'a str>,
os: Option<&'a str>, os: Option<&'a str>,
@@ -22,6 +23,7 @@ impl<'a> SamlAuthLauncher<'a> {
pub fn new(server: &'a str) -> Self { pub fn new(server: &'a str) -> Self {
Self { Self {
server, server,
gateway: false,
saml_request: None, saml_request: None,
user_agent: None, user_agent: None,
os: None, os: None,
@@ -33,6 +35,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 { pub fn saml_request(mut self, saml_request: &'a str) -> Self {
self.saml_request = Some(saml_request); self.saml_request = Some(saml_request);
self self
@@ -78,6 +85,10 @@ impl<'a> SamlAuthLauncher<'a> {
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);
if self.gateway {
auth_cmd.arg("--gateway");
}
if let Some(saml_request) = self.saml_request { if let Some(saml_request) = self.saml_request {
auth_cmd.arg("--saml-request").arg(saml_request); auth_cmd.arg("--saml-request").arg(saml_request);
} }

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,5 +1,7 @@
pub(crate) mod command_traits; pub(crate) mod command_traits;
pub mod auth_launcher; pub mod auth_launcher;
#[cfg(feature = "browser-auth")]
pub mod browser_authenticator;
pub mod gui_launcher; pub mod gui_launcher;
pub mod service_launcher; pub mod service_launcher;

View File

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

View File

@@ -38,3 +38,7 @@ pub fn normalize_server(server: &str) -> anyhow::Result<String> {
Ok(normalized_url) Ok(normalized_url)
} }
pub fn remove_url_scheme(s: &str) -> String {
s.replace("http://", "").replace("https://", "")
}