mirror of
				https://github.com/yuezk/GlobalProtect-openconnect.git
				synced 2025-05-20 07:26:58 -04:00 
			
		
		
		
	Move new code
This commit is contained in:
		
							
								
								
									
										23
									
								
								apps/gpauth/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								apps/gpauth/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "gpauth"
 | 
			
		||||
version.workspace = true
 | 
			
		||||
edition.workspace = true
 | 
			
		||||
license.workspace = true
 | 
			
		||||
 | 
			
		||||
[build-dependencies]
 | 
			
		||||
tauri-build = { version = "1.5", features = [] }
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
gpapi = { path = "../../crates/gpapi", features = ["tauri"] }
 | 
			
		||||
anyhow.workspace = true
 | 
			
		||||
clap.workspace = true
 | 
			
		||||
env_logger.workspace = true
 | 
			
		||||
log.workspace = true
 | 
			
		||||
regex.workspace = true
 | 
			
		||||
serde_json.workspace = true
 | 
			
		||||
tokio.workspace = true
 | 
			
		||||
tokio-util.workspace = true
 | 
			
		||||
tempfile.workspace = true
 | 
			
		||||
webkit2gtk = "0.18.2"
 | 
			
		||||
tauri = { workspace = true, features = ["http-all"] }
 | 
			
		||||
compile-time.workspace = true
 | 
			
		||||
							
								
								
									
										3
									
								
								apps/gpauth/build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								apps/gpauth/build.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
fn main() {
 | 
			
		||||
  tauri_build::build()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/128x128.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/128x128.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 3.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/128x128@2x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/128x128@2x.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 6.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/32x32.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 974 B  | 
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.icns
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.icns
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 85 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/gpauth/icons/icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 14 KiB  | 
							
								
								
									
										11
									
								
								apps/gpauth/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/gpauth/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <title>GlobalProtect Login</title>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
  <p>Redirecting to GlobalProtect Login...</p>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										449
									
								
								apps/gpauth/src/auth_window.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										449
									
								
								apps/gpauth/src/auth_window.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,449 @@
 | 
			
		||||
use std::{
 | 
			
		||||
  rc::Rc,
 | 
			
		||||
  sync::Arc,
 | 
			
		||||
  time::{Duration, Instant},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
use anyhow::bail;
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  auth::SamlAuthData,
 | 
			
		||||
  portal::{prelogin, Prelogin},
 | 
			
		||||
  utils::{redact::redact_uri, window::WindowExt},
 | 
			
		||||
};
 | 
			
		||||
use log::{info, warn};
 | 
			
		||||
use regex::Regex;
 | 
			
		||||
use tauri::{AppHandle, Window, WindowEvent, WindowUrl};
 | 
			
		||||
use tokio::sync::{mpsc, oneshot, RwLock};
 | 
			
		||||
use tokio_util::sync::CancellationToken;
 | 
			
		||||
use webkit2gtk::{
 | 
			
		||||
  gio::Cancellable,
 | 
			
		||||
  glib::{GString, TimeSpan},
 | 
			
		||||
  LoadEvent, URIResponse, URIResponseExt, WebContextExt, WebResource, WebResourceExt, WebView,
 | 
			
		||||
  WebViewExt, WebsiteDataManagerExtManual, WebsiteDataTypes,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
enum AuthDataError {
 | 
			
		||||
  /// 1. Found auth data in headers/body but it's invalid
 | 
			
		||||
  /// 2. Loaded an empty page, failed to load page. etc.
 | 
			
		||||
  Invalid,
 | 
			
		||||
  /// No auth data found in headers/body
 | 
			
		||||
  NotFound,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AuthResult = Result<SamlAuthData, AuthDataError>;
 | 
			
		||||
 | 
			
		||||
pub(crate) struct AuthWindow<'a> {
 | 
			
		||||
  app_handle: AppHandle,
 | 
			
		||||
  server: &'a str,
 | 
			
		||||
  saml_request: &'a str,
 | 
			
		||||
  user_agent: &'a str,
 | 
			
		||||
  clean: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> AuthWindow<'a> {
 | 
			
		||||
  pub fn new(app_handle: AppHandle) -> Self {
 | 
			
		||||
    Self {
 | 
			
		||||
      app_handle,
 | 
			
		||||
      server: "",
 | 
			
		||||
      saml_request: "",
 | 
			
		||||
      user_agent: "",
 | 
			
		||||
      clean: false,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn server(mut self, server: &'a str) -> Self {
 | 
			
		||||
    self.server = server;
 | 
			
		||||
    self
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn saml_request(mut self, saml_request: &'a str) -> Self {
 | 
			
		||||
    self.saml_request = saml_request;
 | 
			
		||||
    self
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn user_agent(mut self, user_agent: &'a str) -> Self {
 | 
			
		||||
    self.user_agent = user_agent;
 | 
			
		||||
    self
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn clean(mut self, clean: bool) -> Self {
 | 
			
		||||
    self.clean = clean;
 | 
			
		||||
    self
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn open(&self) -> anyhow::Result<SamlAuthData> {
 | 
			
		||||
    info!("Open auth window, user_agent: {}", self.user_agent);
 | 
			
		||||
 | 
			
		||||
    let window = Window::builder(&self.app_handle, "auth_window", WindowUrl::default())
 | 
			
		||||
      .title("GlobalProtect Login")
 | 
			
		||||
      .user_agent(self.user_agent)
 | 
			
		||||
      .focused(true)
 | 
			
		||||
      .visible(false)
 | 
			
		||||
      .center()
 | 
			
		||||
      .build()?;
 | 
			
		||||
 | 
			
		||||
    let window = Arc::new(window);
 | 
			
		||||
 | 
			
		||||
    let cancel_token = CancellationToken::new();
 | 
			
		||||
    let cancel_token_clone = cancel_token.clone();
 | 
			
		||||
 | 
			
		||||
    window.on_window_event(move |event| {
 | 
			
		||||
      if let WindowEvent::CloseRequested { .. } = event {
 | 
			
		||||
        cancel_token_clone.cancel();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let window_clone = Arc::clone(&window);
 | 
			
		||||
    let timeout_secs = 15;
 | 
			
		||||
    tokio::spawn(async move {
 | 
			
		||||
      tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
 | 
			
		||||
      let visible = window_clone.is_visible().unwrap_or(false);
 | 
			
		||||
      if !visible {
 | 
			
		||||
        info!("Try to raise auth window after {} seconds", timeout_secs);
 | 
			
		||||
        raise_window(&window_clone);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    tokio::select! {
 | 
			
		||||
      _ = cancel_token.cancelled() => {
 | 
			
		||||
        bail!("Auth cancelled");
 | 
			
		||||
      }
 | 
			
		||||
      saml_result = self.auth_loop(&window) => {
 | 
			
		||||
        window.close()?;
 | 
			
		||||
        saml_result
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async fn auth_loop(&self, window: &Arc<Window>) -> anyhow::Result<SamlAuthData> {
 | 
			
		||||
    let saml_request = self.saml_request.to_string();
 | 
			
		||||
    let (auth_result_tx, mut auth_result_rx) = mpsc::unbounded_channel::<AuthResult>();
 | 
			
		||||
    let raise_window_cancel_token: Arc<RwLock<Option<CancellationToken>>> = Default::default();
 | 
			
		||||
 | 
			
		||||
    if self.clean {
 | 
			
		||||
      clear_webview_cookies(window).await?;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let raise_window_cancel_token_clone = Arc::clone(&raise_window_cancel_token);
 | 
			
		||||
    window.with_webview(move |wv| {
 | 
			
		||||
      let wv = wv.inner();
 | 
			
		||||
 | 
			
		||||
      // Load the initial SAML request
 | 
			
		||||
      load_saml_request(&wv, &saml_request);
 | 
			
		||||
 | 
			
		||||
      let auth_result_tx_clone = auth_result_tx.clone();
 | 
			
		||||
      wv.connect_load_changed(move |wv, event| {
 | 
			
		||||
        if event == LoadEvent::Started {
 | 
			
		||||
          let Ok(mut cancel_token) = raise_window_cancel_token_clone.try_write() else {
 | 
			
		||||
            return;
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          // Cancel the raise window task
 | 
			
		||||
          if let Some(cancel_token) = cancel_token.take() {
 | 
			
		||||
            cancel_token.cancel();
 | 
			
		||||
          }
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if event != LoadEvent::Finished {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if let Some(main_resource) = wv.main_resource() {
 | 
			
		||||
          let uri = main_resource.uri().unwrap_or("".into());
 | 
			
		||||
 | 
			
		||||
          if uri.is_empty() {
 | 
			
		||||
            warn!("Loaded an empty uri");
 | 
			
		||||
            send_auth_result(&auth_result_tx_clone, Err(AuthDataError::Invalid));
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          info!("Loaded uri: {}", redact_uri(&uri));
 | 
			
		||||
          read_auth_data(&main_resource, auth_result_tx_clone.clone());
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      wv.connect_load_failed_with_tls_errors(|_wv, uri, cert, err| {
 | 
			
		||||
        let redacted_uri = redact_uri(uri);
 | 
			
		||||
        warn!(
 | 
			
		||||
          "Failed to load uri: {} with error: {}, cert: {}",
 | 
			
		||||
          redacted_uri, err, cert
 | 
			
		||||
        );
 | 
			
		||||
        true
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      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));
 | 
			
		||||
        // true to stop other handlers from being invoked for the event. false to propagate the event further.
 | 
			
		||||
        true
 | 
			
		||||
      });
 | 
			
		||||
    })?;
 | 
			
		||||
 | 
			
		||||
    let portal = self.server.to_string();
 | 
			
		||||
    let user_agent = self.user_agent.to_string();
 | 
			
		||||
 | 
			
		||||
    loop {
 | 
			
		||||
      if let Some(auth_result) = auth_result_rx.recv().await {
 | 
			
		||||
        match auth_result {
 | 
			
		||||
          Ok(auth_data) => return Ok(auth_data),
 | 
			
		||||
          Err(AuthDataError::NotFound) => {
 | 
			
		||||
            info!("No auth data found, it may not be the /SAML20/SP/ACS endpoint");
 | 
			
		||||
 | 
			
		||||
            // The user may need to interact with the auth window, raise it in 3 seconds
 | 
			
		||||
            if !window.is_visible().unwrap_or(false) {
 | 
			
		||||
              let window = Arc::clone(window);
 | 
			
		||||
              let cancel_token = CancellationToken::new();
 | 
			
		||||
 | 
			
		||||
              raise_window_cancel_token
 | 
			
		||||
                .write()
 | 
			
		||||
                .await
 | 
			
		||||
                .replace(cancel_token.clone());
 | 
			
		||||
 | 
			
		||||
              tokio::spawn(async move {
 | 
			
		||||
                let delay_secs = 1;
 | 
			
		||||
 | 
			
		||||
                info!("Raise window in {} second(s)", delay_secs);
 | 
			
		||||
                tokio::select! {
 | 
			
		||||
                  _ = tokio::time::sleep(Duration::from_secs(delay_secs)) => {
 | 
			
		||||
                    raise_window(&window);
 | 
			
		||||
                  }
 | 
			
		||||
                  _ = cancel_token.cancelled() => {
 | 
			
		||||
                    info!("Raise window cancelled");
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          Err(AuthDataError::Invalid) => {
 | 
			
		||||
            info!("Got invalid auth data, retrying...");
 | 
			
		||||
 | 
			
		||||
            window.with_webview(|wv| {
 | 
			
		||||
              let wv = wv.inner();
 | 
			
		||||
              wv.run_javascript(r#"
 | 
			
		||||
                  var loading = document.createElement("div");
 | 
			
		||||
                  loading.innerHTML = '<div style="position: absolute; width: 100%; text-align: center; font-size: 20px; font-weight: bold; top: 50%; left: 50%; transform: translate(-50%, -50%);">Got invalid token, retrying...</div>';
 | 
			
		||||
                  loading.style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.85); z-index: 99999;";
 | 
			
		||||
                  document.body.appendChild(loading);
 | 
			
		||||
              "#,
 | 
			
		||||
                  Cancellable::NONE,
 | 
			
		||||
                  |_| info!("Injected loading element successfully"),
 | 
			
		||||
              );
 | 
			
		||||
            })?;
 | 
			
		||||
 | 
			
		||||
            let saml_request = portal_prelogin(&portal, &user_agent).await?;
 | 
			
		||||
            window.with_webview(move |wv| {
 | 
			
		||||
              let wv = wv.inner();
 | 
			
		||||
              load_saml_request(&wv, &saml_request);
 | 
			
		||||
            })?;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn raise_window(window: &Arc<Window>) {
 | 
			
		||||
  let visible = window.is_visible().unwrap_or(false);
 | 
			
		||||
  if !visible {
 | 
			
		||||
    if let Err(err) = window.raise() {
 | 
			
		||||
      warn!("Failed to raise window: {}", err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) async fn portal_prelogin(portal: &str, user_agent: &str) -> anyhow::Result<String> {
 | 
			
		||||
  info!("Portal prelogin...");
 | 
			
		||||
  match prelogin(portal, user_agent).await? {
 | 
			
		||||
    Prelogin::Saml(prelogin) => Ok(prelogin.saml_request().to_string()),
 | 
			
		||||
    Prelogin::Standard(_) => Err(anyhow::anyhow!("Received non-SAML prelogin response")),
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn send_auth_result(auth_result_tx: &mpsc::UnboundedSender<AuthResult>, auth_result: AuthResult) {
 | 
			
		||||
  if let Err(err) = auth_result_tx.send(auth_result) {
 | 
			
		||||
    warn!("Failed to send auth event: {}", err);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn load_saml_request(wv: &Rc<WebView>, saml_request: &str) {
 | 
			
		||||
  if saml_request.starts_with("http") {
 | 
			
		||||
    info!("Load the SAML request as URI...");
 | 
			
		||||
    wv.load_uri(saml_request);
 | 
			
		||||
  } else {
 | 
			
		||||
    info!("Load the SAML request as HTML...");
 | 
			
		||||
    wv.load_html(saml_request, None);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn read_auth_data_from_headers(response: &URIResponse) -> AuthResult {
 | 
			
		||||
  response.http_headers().map_or_else(
 | 
			
		||||
    || {
 | 
			
		||||
      info!("No headers found in response");
 | 
			
		||||
      Err(AuthDataError::NotFound)
 | 
			
		||||
    },
 | 
			
		||||
    |mut headers| match headers.get("saml-auth-status") {
 | 
			
		||||
      Some(status) if status == "1" => {
 | 
			
		||||
        let username = headers.get("saml-username").map(GString::into);
 | 
			
		||||
        let prelogin_cookie = headers.get("prelogin-cookie").map(GString::into);
 | 
			
		||||
        let portal_userauthcookie = headers.get("portal-userauthcookie").map(GString::into);
 | 
			
		||||
 | 
			
		||||
        if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
 | 
			
		||||
          return Ok(SamlAuthData::new(
 | 
			
		||||
            username.unwrap(),
 | 
			
		||||
            prelogin_cookie,
 | 
			
		||||
            portal_userauthcookie,
 | 
			
		||||
          ));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        info!("Found invalid auth data in headers");
 | 
			
		||||
        Err(AuthDataError::Invalid)
 | 
			
		||||
      }
 | 
			
		||||
      Some(status) => {
 | 
			
		||||
        info!("Found invalid SAML status: {} in headers", status);
 | 
			
		||||
        Err(AuthDataError::Invalid)
 | 
			
		||||
      }
 | 
			
		||||
      None => {
 | 
			
		||||
        info!("No saml-auth-status header found");
 | 
			
		||||
        Err(AuthDataError::NotFound)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn read_auth_data_from_body<F>(main_resource: &WebResource, callback: F)
 | 
			
		||||
where
 | 
			
		||||
  F: FnOnce(AuthResult) + Send + 'static,
 | 
			
		||||
{
 | 
			
		||||
  main_resource.data(Cancellable::NONE, |data| match data {
 | 
			
		||||
    Ok(data) => {
 | 
			
		||||
      let html = String::from_utf8_lossy(&data);
 | 
			
		||||
      callback(read_auth_data_from_html(&html));
 | 
			
		||||
    }
 | 
			
		||||
    Err(err) => {
 | 
			
		||||
      info!("Failed to read response body: {}", err);
 | 
			
		||||
      callback(Err(AuthDataError::Invalid))
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn read_auth_data_from_html(html: &str) -> AuthResult {
 | 
			
		||||
  if html.contains("Temporarily Unavailable") {
 | 
			
		||||
    info!("Found 'Temporarily Unavailable' in HTML, auth failed");
 | 
			
		||||
    return Err(AuthDataError::Invalid);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  match parse_xml_tag(html, "saml-auth-status") {
 | 
			
		||||
    Some(saml_status) if saml_status == "1" => {
 | 
			
		||||
      let username = parse_xml_tag(html, "saml-username");
 | 
			
		||||
      let prelogin_cookie = parse_xml_tag(html, "prelogin-cookie");
 | 
			
		||||
      let portal_userauthcookie = parse_xml_tag(html, "portal-userauthcookie");
 | 
			
		||||
 | 
			
		||||
      if SamlAuthData::check(&username, &prelogin_cookie, &portal_userauthcookie) {
 | 
			
		||||
        return Ok(SamlAuthData::new(
 | 
			
		||||
          username.unwrap(),
 | 
			
		||||
          prelogin_cookie,
 | 
			
		||||
          portal_userauthcookie,
 | 
			
		||||
        ));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      info!("Found invalid auth data in HTML");
 | 
			
		||||
      Err(AuthDataError::Invalid)
 | 
			
		||||
    }
 | 
			
		||||
    Some(status) => {
 | 
			
		||||
      info!("Found invalid SAML status {} in HTML", status);
 | 
			
		||||
      Err(AuthDataError::Invalid)
 | 
			
		||||
    }
 | 
			
		||||
    None => {
 | 
			
		||||
      info!("No auth data found in HTML");
 | 
			
		||||
      Err(AuthDataError::NotFound)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn read_auth_data(main_resource: &WebResource, auth_result_tx: mpsc::UnboundedSender<AuthResult>) {
 | 
			
		||||
  if main_resource.response().is_none() {
 | 
			
		||||
    info!("No response found in main resource");
 | 
			
		||||
    send_auth_result(&auth_result_tx, Err(AuthDataError::Invalid));
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let response = main_resource.response().unwrap();
 | 
			
		||||
  info!("Trying to read auth data from response headers...");
 | 
			
		||||
 | 
			
		||||
  match read_auth_data_from_headers(&response) {
 | 
			
		||||
    Ok(auth_data) => {
 | 
			
		||||
      info!("Got auth data from headers");
 | 
			
		||||
      send_auth_result(&auth_result_tx, Ok(auth_data));
 | 
			
		||||
    }
 | 
			
		||||
    Err(AuthDataError::Invalid) => {
 | 
			
		||||
      info!("Found invalid auth data in headers, trying to read from body...");
 | 
			
		||||
      read_auth_data_from_body(main_resource, move |auth_result| {
 | 
			
		||||
        // Since we have already found invalid auth data in headers, which means this could be the `/SAML20/SP/ACS` endpoint
 | 
			
		||||
        // any error result from body should be considered as invalid, and trigger a retry
 | 
			
		||||
        let auth_result = auth_result.map_err(|_| AuthDataError::Invalid);
 | 
			
		||||
        send_auth_result(&auth_result_tx, auth_result);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    Err(AuthDataError::NotFound) => {
 | 
			
		||||
      info!("No auth data found in headers, trying to read from body...");
 | 
			
		||||
      read_auth_data_from_body(main_resource, move |auth_result| {
 | 
			
		||||
        send_auth_result(&auth_result_tx, auth_result)
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
 | 
			
		||||
  let re = Regex::new(&format!("<{}>(.*)</{}>", tag, tag)).unwrap();
 | 
			
		||||
  re.captures(html)
 | 
			
		||||
    .and_then(|captures| captures.get(1))
 | 
			
		||||
    .map(|m| m.as_str().to_string())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) async fn clear_webview_cookies(window: &Window) -> anyhow::Result<()> {
 | 
			
		||||
  let (tx, rx) = oneshot::channel::<Result<(), String>>();
 | 
			
		||||
 | 
			
		||||
  window.with_webview(|wv| {
 | 
			
		||||
    let send_result = move |result: Result<(), String>| {
 | 
			
		||||
      if let Err(err) = tx.send(result) {
 | 
			
		||||
        info!("Failed to send result: {:?}", err);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let wv = wv.inner();
 | 
			
		||||
    let context = match wv.context() {
 | 
			
		||||
      Some(context) => context,
 | 
			
		||||
      None => {
 | 
			
		||||
        send_result(Err("No webview context found".into()));
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    let data_manager = match context.website_data_manager() {
 | 
			
		||||
      Some(manager) => manager,
 | 
			
		||||
      None => {
 | 
			
		||||
        send_result(Err("No data manager found".into()));
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let now = Instant::now();
 | 
			
		||||
    data_manager.clear(
 | 
			
		||||
      WebsiteDataTypes::COOKIES,
 | 
			
		||||
      TimeSpan(0),
 | 
			
		||||
      Cancellable::NONE,
 | 
			
		||||
      move |result| match result {
 | 
			
		||||
        Err(err) => {
 | 
			
		||||
          send_result(Err(err.to_string()));
 | 
			
		||||
        }
 | 
			
		||||
        Ok(_) => {
 | 
			
		||||
          info!("Cookies cleared in {} ms", now.elapsed().as_millis());
 | 
			
		||||
          send_result(Ok(()));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  })?;
 | 
			
		||||
 | 
			
		||||
  rx.await?.map_err(|err| anyhow::anyhow!(err))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										138
									
								
								apps/gpauth/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								apps/gpauth/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,138 @@
 | 
			
		||||
use clap::Parser;
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  auth::{SamlAuthData, SamlAuthResult},
 | 
			
		||||
  utils::{normalize_server, openssl},
 | 
			
		||||
  GP_USER_AGENT,
 | 
			
		||||
};
 | 
			
		||||
use log::{info, LevelFilter};
 | 
			
		||||
use serde_json::json;
 | 
			
		||||
use tauri::{App, AppHandle, RunEvent};
 | 
			
		||||
use tempfile::NamedTempFile;
 | 
			
		||||
 | 
			
		||||
use crate::auth_window::{portal_prelogin, AuthWindow};
 | 
			
		||||
 | 
			
		||||
const VERSION: &str = concat!(
 | 
			
		||||
  env!("CARGO_PKG_VERSION"),
 | 
			
		||||
  " (",
 | 
			
		||||
  compile_time::date_str!(),
 | 
			
		||||
  ")"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
#[derive(Parser, Clone)]
 | 
			
		||||
#[command(version = VERSION)]
 | 
			
		||||
struct Cli {
 | 
			
		||||
  server: String,
 | 
			
		||||
  #[arg(long)]
 | 
			
		||||
  saml_request: Option<String>,
 | 
			
		||||
  #[arg(long, default_value = GP_USER_AGENT)]
 | 
			
		||||
  user_agent: String,
 | 
			
		||||
  #[arg(long)]
 | 
			
		||||
  hidpi: bool,
 | 
			
		||||
  #[arg(long)]
 | 
			
		||||
  fix_openssl: bool,
 | 
			
		||||
  #[arg(long)]
 | 
			
		||||
  clean: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Cli {
 | 
			
		||||
  async fn run(&mut self) -> anyhow::Result<()> {
 | 
			
		||||
    let mut openssl_conf = self.prepare_env()?;
 | 
			
		||||
 | 
			
		||||
    self.server = normalize_server(&self.server)?;
 | 
			
		||||
    // Get the initial SAML request
 | 
			
		||||
    let saml_request = match self.saml_request {
 | 
			
		||||
      Some(ref saml_request) => saml_request.clone(),
 | 
			
		||||
      None => portal_prelogin(&self.server, &self.user_agent).await?,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    self.saml_request.replace(saml_request);
 | 
			
		||||
 | 
			
		||||
    let app = create_app(self.clone())?;
 | 
			
		||||
 | 
			
		||||
    app.run(move |_app_handle, event| {
 | 
			
		||||
      if let RunEvent::Exit = event {
 | 
			
		||||
        if let Some(file) = openssl_conf.take() {
 | 
			
		||||
          if let Err(err) = file.close() {
 | 
			
		||||
            info!("Error closing OpenSSL config file: {}", err);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fn prepare_env(&self) -> anyhow::Result<Option<NamedTempFile>> {
 | 
			
		||||
    std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
 | 
			
		||||
 | 
			
		||||
    if self.hidpi {
 | 
			
		||||
      info!("Setting GDK_SCALE=2 and GDK_DPI_SCALE=0.5");
 | 
			
		||||
 | 
			
		||||
      std::env::set_var("GDK_SCALE", "2");
 | 
			
		||||
      std::env::set_var("GDK_DPI_SCALE", "0.5");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if self.fix_openssl {
 | 
			
		||||
      info!("Fixing OpenSSL environment");
 | 
			
		||||
      let file = openssl::fix_openssl_env()?;
 | 
			
		||||
 | 
			
		||||
      return Ok(Some(file));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(None)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async fn saml_auth(&self, app_handle: AppHandle) -> anyhow::Result<SamlAuthData> {
 | 
			
		||||
    let auth_window = AuthWindow::new(app_handle)
 | 
			
		||||
      .server(&self.server)
 | 
			
		||||
      .user_agent(&self.user_agent)
 | 
			
		||||
      .saml_request(self.saml_request.as_ref().unwrap())
 | 
			
		||||
      .clean(self.clean);
 | 
			
		||||
 | 
			
		||||
    auth_window.open().await
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn create_app(cli: Cli) -> anyhow::Result<App> {
 | 
			
		||||
  let app = tauri::Builder::default()
 | 
			
		||||
    .setup(|app| {
 | 
			
		||||
      let app_handle = app.handle();
 | 
			
		||||
 | 
			
		||||
      tauri::async_runtime::spawn(async move {
 | 
			
		||||
        let auth_result = match cli.saml_auth(app_handle.clone()).await {
 | 
			
		||||
          Ok(auth_data) => SamlAuthResult::Success(auth_data),
 | 
			
		||||
          Err(err) => SamlAuthResult::Failure(format!("{}", err)),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        println!("{}", json!(auth_result));
 | 
			
		||||
      });
 | 
			
		||||
      Ok(())
 | 
			
		||||
    })
 | 
			
		||||
    .build(tauri::generate_context!())?;
 | 
			
		||||
 | 
			
		||||
  Ok(app)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn init_logger() {
 | 
			
		||||
  env_logger::builder().filter_level(LevelFilter::Info).init();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn run() {
 | 
			
		||||
  let mut cli = Cli::parse();
 | 
			
		||||
 | 
			
		||||
  init_logger();
 | 
			
		||||
  info!("gpauth started: {}", VERSION);
 | 
			
		||||
 | 
			
		||||
  if let Err(err) = cli.run().await {
 | 
			
		||||
    eprintln!("\nError: {}", err);
 | 
			
		||||
 | 
			
		||||
    if err.to_string().contains("unsafe legacy renegotiation") && !cli.fix_openssl {
 | 
			
		||||
      eprintln!("\nRe-run it with the `--fix-openssl` option to work around this issue, e.g.:\n");
 | 
			
		||||
      // Print the command
 | 
			
		||||
      let args = std::env::args().collect::<Vec<_>>();
 | 
			
		||||
      eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" "));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    std::process::exit(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								apps/gpauth/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								apps/gpauth/src/main.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
 | 
			
		||||
 | 
			
		||||
mod auth_window;
 | 
			
		||||
mod cli;
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
async fn main() {
 | 
			
		||||
  cli::run().await;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								apps/gpauth/tauri.conf.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								apps/gpauth/tauri.conf.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://cdn.jsdelivr.net/gh/tauri-apps/tauri@tauri-v1.5.0/tooling/cli/schema.json",
 | 
			
		||||
  "build": {
 | 
			
		||||
    "distDir": [
 | 
			
		||||
      "index.html"
 | 
			
		||||
    ],
 | 
			
		||||
    "devPath": [
 | 
			
		||||
      "index.html"
 | 
			
		||||
    ],
 | 
			
		||||
    "beforeDevCommand": "",
 | 
			
		||||
    "beforeBuildCommand": "",
 | 
			
		||||
    "withGlobalTauri": false
 | 
			
		||||
  },
 | 
			
		||||
  "package": {
 | 
			
		||||
    "productName": "gpauth",
 | 
			
		||||
    "version": "0.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "tauri": {
 | 
			
		||||
    "allowlist": {
 | 
			
		||||
      "all": false,
 | 
			
		||||
      "http": {
 | 
			
		||||
        "all": true,
 | 
			
		||||
        "request": true,
 | 
			
		||||
        "scope": [
 | 
			
		||||
          "http://**",
 | 
			
		||||
          "https://**"
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "bundle": {
 | 
			
		||||
      "active": true,
 | 
			
		||||
      "targets": "deb",
 | 
			
		||||
      "identifier": "com.yuezk.gpauth",
 | 
			
		||||
      "icon": [
 | 
			
		||||
        "icons/32x32.png",
 | 
			
		||||
        "icons/128x128.png",
 | 
			
		||||
        "icons/128x128@2x.png",
 | 
			
		||||
        "icons/icon.icns",
 | 
			
		||||
        "icons/icon.ico"
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    "security": {
 | 
			
		||||
      "csp": null
 | 
			
		||||
    },
 | 
			
		||||
    "windows": []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								apps/gpclient/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								apps/gpclient/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "gpclient"
 | 
			
		||||
authors.workspace = true
 | 
			
		||||
version.workspace = true
 | 
			
		||||
edition.workspace = true
 | 
			
		||||
license.workspace = true
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
gpapi = { path = "../../crates/gpapi" }
 | 
			
		||||
openconnect = { path = "../../crates/openconnect" }
 | 
			
		||||
anyhow.workspace = true
 | 
			
		||||
clap.workspace = true
 | 
			
		||||
env_logger.workspace = true
 | 
			
		||||
inquire = "0.6.2"
 | 
			
		||||
log.workspace = true
 | 
			
		||||
tokio.workspace = true
 | 
			
		||||
sysinfo.workspace = true
 | 
			
		||||
serde_json.workspace = true
 | 
			
		||||
whoami.workspace = true
 | 
			
		||||
tempfile.workspace = true
 | 
			
		||||
reqwest.workspace = true
 | 
			
		||||
directories = "5.0"
 | 
			
		||||
compile-time.workspace = true
 | 
			
		||||
							
								
								
									
										101
									
								
								apps/gpclient/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								apps/gpclient/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
use clap::{Parser, Subcommand};
 | 
			
		||||
use gpapi::utils::openssl;
 | 
			
		||||
use log::{info, LevelFilter};
 | 
			
		||||
use tempfile::NamedTempFile;
 | 
			
		||||
 | 
			
		||||
use crate::{
 | 
			
		||||
  connect::{ConnectArgs, ConnectHandler},
 | 
			
		||||
  disconnect::DisconnectHandler,
 | 
			
		||||
  launch_gui::{LaunchGuiArgs, LaunchGuiHandler},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const VERSION: &str = concat!(
 | 
			
		||||
  env!("CARGO_PKG_VERSION"),
 | 
			
		||||
  " (",
 | 
			
		||||
  compile_time::date_str!(),
 | 
			
		||||
  ")"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand)]
 | 
			
		||||
enum CliCommand {
 | 
			
		||||
  #[command(about = "Connect to a portal server")]
 | 
			
		||||
  Connect(ConnectArgs),
 | 
			
		||||
  #[command(about = "Disconnect from the server")]
 | 
			
		||||
  Disconnect,
 | 
			
		||||
  #[command(about = "Launch the GUI")]
 | 
			
		||||
  LaunchGui(LaunchGuiArgs),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Parser)]
 | 
			
		||||
#[command(
 | 
			
		||||
  version = VERSION,
 | 
			
		||||
  author,
 | 
			
		||||
  about = "The GlobalProtect VPN client, based on OpenConnect, supports the SSO authentication method.",
 | 
			
		||||
  help_template = "\
 | 
			
		||||
{before-help}{name} {version}
 | 
			
		||||
{author}
 | 
			
		||||
 | 
			
		||||
{about}
 | 
			
		||||
 | 
			
		||||
{usage-heading} {usage}
 | 
			
		||||
 | 
			
		||||
{all-args}{after-help}
 | 
			
		||||
"
 | 
			
		||||
)]
 | 
			
		||||
struct Cli {
 | 
			
		||||
  #[command(subcommand)]
 | 
			
		||||
  command: CliCommand,
 | 
			
		||||
 | 
			
		||||
  #[arg(
 | 
			
		||||
    long,
 | 
			
		||||
    help = "Get around the OpenSSL `unsafe legacy renegotiation` error"
 | 
			
		||||
  )]
 | 
			
		||||
  fix_openssl: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Cli {
 | 
			
		||||
  fn fix_openssl(&self) -> anyhow::Result<Option<NamedTempFile>> {
 | 
			
		||||
    if self.fix_openssl {
 | 
			
		||||
      let file = openssl::fix_openssl_env()?;
 | 
			
		||||
      return Ok(Some(file));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(None)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async fn run(&self) -> anyhow::Result<()> {
 | 
			
		||||
    // The temp file will be dropped automatically when the file handle is dropped
 | 
			
		||||
    // So, declare it here to ensure it's not dropped
 | 
			
		||||
    let _file = self.fix_openssl()?;
 | 
			
		||||
 | 
			
		||||
    match &self.command {
 | 
			
		||||
      CliCommand::Connect(args) => ConnectHandler::new(args, self.fix_openssl).handle().await,
 | 
			
		||||
      CliCommand::Disconnect => DisconnectHandler::new().handle(),
 | 
			
		||||
      CliCommand::LaunchGui(args) => LaunchGuiHandler::new(args).handle().await,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn init_logger() {
 | 
			
		||||
  env_logger::builder().filter_level(LevelFilter::Info).init();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) async fn run() {
 | 
			
		||||
  let cli = Cli::parse();
 | 
			
		||||
 | 
			
		||||
  init_logger();
 | 
			
		||||
  info!("gpclient started: {}", VERSION);
 | 
			
		||||
 | 
			
		||||
  if let Err(err) = cli.run().await {
 | 
			
		||||
    eprintln!("\nError: {}", err);
 | 
			
		||||
 | 
			
		||||
    if err.to_string().contains("unsafe legacy renegotiation") && !cli.fix_openssl {
 | 
			
		||||
      eprintln!("\nRe-run it with the `--fix-openssl` option to work around this issue, e.g.:\n");
 | 
			
		||||
      // Print the command
 | 
			
		||||
      let args = std::env::args().collect::<Vec<_>>();
 | 
			
		||||
      eprintln!("{} --fix-openssl {}\n", args[0], args[1..].join(" "));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    std::process::exit(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										150
									
								
								apps/gpclient/src/connect.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								apps/gpclient/src/connect.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,150 @@
 | 
			
		||||
use std::{fs, sync::Arc};
 | 
			
		||||
 | 
			
		||||
use clap::Args;
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  credential::{Credential, PasswordCredential},
 | 
			
		||||
  gateway::gateway_login,
 | 
			
		||||
  gp_params::GpParams,
 | 
			
		||||
  portal::{prelogin, retrieve_config, Prelogin},
 | 
			
		||||
  process::auth_launcher::SamlAuthLauncher,
 | 
			
		||||
  utils::{self, shutdown_signal},
 | 
			
		||||
  GP_USER_AGENT,
 | 
			
		||||
};
 | 
			
		||||
use inquire::{Password, PasswordDisplayMode, Select, Text};
 | 
			
		||||
use log::info;
 | 
			
		||||
use openconnect::Vpn;
 | 
			
		||||
 | 
			
		||||
use crate::GP_CLIENT_LOCK_FILE;
 | 
			
		||||
 | 
			
		||||
#[derive(Args)]
 | 
			
		||||
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"
 | 
			
		||||
  )]
 | 
			
		||||
  gateway: Option<String>,
 | 
			
		||||
  #[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, default_value = GP_USER_AGENT, help = "The user agent to use")]
 | 
			
		||||
  user_agent: String,
 | 
			
		||||
  #[arg(long, help = "The HiDPI mode, useful for high resolution screens")]
 | 
			
		||||
  hidpi: bool,
 | 
			
		||||
  #[arg(long, help = "Do not reuse the remembered authentication cookie")]
 | 
			
		||||
  clean: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) struct ConnectHandler<'a> {
 | 
			
		||||
  args: &'a ConnectArgs,
 | 
			
		||||
  fix_openssl: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> ConnectHandler<'a> {
 | 
			
		||||
  pub(crate) fn new(args: &'a ConnectArgs, fix_openssl: bool) -> Self {
 | 
			
		||||
    Self { args, fix_openssl }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub(crate) async fn handle(&self) -> anyhow::Result<()> {
 | 
			
		||||
    let portal = utils::normalize_server(self.args.server.as_str())?;
 | 
			
		||||
 | 
			
		||||
    let gp_params = GpParams::builder()
 | 
			
		||||
      .user_agent(&self.args.user_agent)
 | 
			
		||||
      .build();
 | 
			
		||||
 | 
			
		||||
    let prelogin = prelogin(&portal, &self.args.user_agent).await?;
 | 
			
		||||
    let portal_credential = self.obtain_portal_credential(&prelogin).await?;
 | 
			
		||||
    let mut portal_config = retrieve_config(&portal, &portal_credential, &gp_params).await?;
 | 
			
		||||
 | 
			
		||||
    let selected_gateway = match &self.args.gateway {
 | 
			
		||||
      Some(gateway) => portal_config
 | 
			
		||||
        .find_gateway(gateway)
 | 
			
		||||
        .ok_or_else(|| anyhow::anyhow!("Cannot find gateway {}", gateway))?,
 | 
			
		||||
      None => {
 | 
			
		||||
        portal_config.sort_gateways(prelogin.region());
 | 
			
		||||
        let gateways = portal_config.gateways();
 | 
			
		||||
 | 
			
		||||
        if gateways.len() > 1 {
 | 
			
		||||
          Select::new("Which gateway do you want to connect to?", gateways)
 | 
			
		||||
            .with_vim_mode(true)
 | 
			
		||||
            .prompt()?
 | 
			
		||||
        } else {
 | 
			
		||||
          gateways[0]
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
      .user_agent(self.args.user_agent.clone())
 | 
			
		||||
      .script(self.args.script.clone())
 | 
			
		||||
      .build();
 | 
			
		||||
 | 
			
		||||
    let vpn = Arc::new(vpn);
 | 
			
		||||
    let vpn_clone = vpn.clone();
 | 
			
		||||
 | 
			
		||||
    // Listen for the interrupt signal in the background
 | 
			
		||||
    tokio::spawn(async move {
 | 
			
		||||
      shutdown_signal().await;
 | 
			
		||||
      info!("Received the interrupt signal, disconnecting...");
 | 
			
		||||
      vpn_clone.disconnect();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    vpn.connect(write_pid_file);
 | 
			
		||||
 | 
			
		||||
    if fs::metadata(GP_CLIENT_LOCK_FILE).is_ok() {
 | 
			
		||||
      info!("Removing PID file");
 | 
			
		||||
      fs::remove_file(GP_CLIENT_LOCK_FILE)?;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async fn obtain_portal_credential(&self, prelogin: &Prelogin) -> anyhow::Result<Credential> {
 | 
			
		||||
    match prelogin {
 | 
			
		||||
      Prelogin::Saml(prelogin) => {
 | 
			
		||||
        SamlAuthLauncher::new(&self.args.server)
 | 
			
		||||
          .user_agent(&self.args.user_agent)
 | 
			
		||||
          .saml_request(prelogin.saml_request())
 | 
			
		||||
          .hidpi(self.args.hidpi)
 | 
			
		||||
          .fix_openssl(self.fix_openssl)
 | 
			
		||||
          .clean(self.args.clean)
 | 
			
		||||
          .launch()
 | 
			
		||||
          .await
 | 
			
		||||
      }
 | 
			
		||||
      Prelogin::Standard(prelogin) => {
 | 
			
		||||
        println!("{}", prelogin.auth_message());
 | 
			
		||||
 | 
			
		||||
        let user = self.args.user.as_ref().map_or_else(
 | 
			
		||||
          || Text::new(&format!("{}:", prelogin.label_username())).prompt(),
 | 
			
		||||
          |user| Ok(user.to_owned()),
 | 
			
		||||
        )?;
 | 
			
		||||
        let password = Password::new(&format!("{}:", prelogin.label_password()))
 | 
			
		||||
          .without_confirmation()
 | 
			
		||||
          .with_display_mode(PasswordDisplayMode::Masked)
 | 
			
		||||
          .prompt()?;
 | 
			
		||||
 | 
			
		||||
        let password_cred = PasswordCredential::new(&user, &password);
 | 
			
		||||
 | 
			
		||||
        Ok(password_cred.into())
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn write_pid_file() {
 | 
			
		||||
  let pid = std::process::id();
 | 
			
		||||
 | 
			
		||||
  fs::write(GP_CLIENT_LOCK_FILE, pid.to_string()).unwrap();
 | 
			
		||||
  info!("Wrote PID {} to {}", pid, GP_CLIENT_LOCK_FILE);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								apps/gpclient/src/disconnect.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								apps/gpclient/src/disconnect.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
use crate::GP_CLIENT_LOCK_FILE;
 | 
			
		||||
use log::{info, warn};
 | 
			
		||||
use std::fs;
 | 
			
		||||
use sysinfo::{Pid, ProcessExt, Signal, System, SystemExt};
 | 
			
		||||
 | 
			
		||||
pub(crate) struct DisconnectHandler;
 | 
			
		||||
 | 
			
		||||
impl DisconnectHandler {
 | 
			
		||||
  pub(crate) fn new() -> Self {
 | 
			
		||||
    Self
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub(crate) fn handle(&self) -> anyhow::Result<()> {
 | 
			
		||||
    if fs::metadata(GP_CLIENT_LOCK_FILE).is_err() {
 | 
			
		||||
      warn!("PID file not found, maybe the client is not running");
 | 
			
		||||
      return Ok(());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let pid = fs::read_to_string(GP_CLIENT_LOCK_FILE)?;
 | 
			
		||||
    let pid = pid.trim().parse::<usize>()?;
 | 
			
		||||
    let s = System::new_all();
 | 
			
		||||
 | 
			
		||||
    if let Some(process) = s.process(Pid::from(pid)) {
 | 
			
		||||
      info!("Found process {}, killing...", pid);
 | 
			
		||||
      if process.kill_with(Signal::Interrupt).is_none() {
 | 
			
		||||
        warn!("Failed to kill process {}", pid);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								apps/gpclient/src/launch_gui.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								apps/gpclient/src/launch_gui.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,88 @@
 | 
			
		||||
use std::{collections::HashMap, fs, path::PathBuf};
 | 
			
		||||
 | 
			
		||||
use clap::Args;
 | 
			
		||||
use directories::ProjectDirs;
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  process::service_launcher::ServiceLauncher,
 | 
			
		||||
  utils::{endpoint::http_endpoint, env_file, shutdown_signal},
 | 
			
		||||
};
 | 
			
		||||
use log::info;
 | 
			
		||||
 | 
			
		||||
#[derive(Args)]
 | 
			
		||||
pub(crate) struct LaunchGuiArgs {
 | 
			
		||||
  #[clap(long, help = "Launch the GUI minimized")]
 | 
			
		||||
  minimized: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) struct LaunchGuiHandler<'a> {
 | 
			
		||||
  args: &'a LaunchGuiArgs,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> LaunchGuiHandler<'a> {
 | 
			
		||||
  pub(crate) fn new(args: &'a LaunchGuiArgs) -> Self {
 | 
			
		||||
    Self { args }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub(crate) async fn handle(&self) -> anyhow::Result<()> {
 | 
			
		||||
    // `launch-gui`cannot be run as root
 | 
			
		||||
    let user = whoami::username();
 | 
			
		||||
    if user == "root" {
 | 
			
		||||
      anyhow::bail!("`launch-gui` cannot be run as root");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if try_active_gui().await.is_ok() {
 | 
			
		||||
      info!("The GUI is already running");
 | 
			
		||||
      return Ok(());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tokio::spawn(async move {
 | 
			
		||||
      shutdown_signal().await;
 | 
			
		||||
      info!("Shutting down...");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let log_file = get_log_file()?;
 | 
			
		||||
    let log_file_path = log_file.to_string_lossy().to_string();
 | 
			
		||||
 | 
			
		||||
    info!("Log file: {}", log_file_path);
 | 
			
		||||
 | 
			
		||||
    let mut extra_envs = HashMap::<String, String>::new();
 | 
			
		||||
    extra_envs.insert("GP_LOG_FILE".into(), log_file_path.clone());
 | 
			
		||||
 | 
			
		||||
    // Persist the environment variables to a file
 | 
			
		||||
    let env_file = env_file::persist_env_vars(Some(extra_envs))?;
 | 
			
		||||
    let env_file = env_file.into_temp_path();
 | 
			
		||||
    let env_file_path = env_file.to_string_lossy().to_string();
 | 
			
		||||
 | 
			
		||||
    let exit_status = ServiceLauncher::new()
 | 
			
		||||
      .minimized(self.args.minimized)
 | 
			
		||||
      .env_file(&env_file_path)
 | 
			
		||||
      .log_file(&log_file_path)
 | 
			
		||||
      .launch()
 | 
			
		||||
      .await?;
 | 
			
		||||
 | 
			
		||||
    info!("Service exited with status: {}", exit_status);
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn try_active_gui() -> anyhow::Result<()> {
 | 
			
		||||
  let service_endpoint = http_endpoint().await?;
 | 
			
		||||
 | 
			
		||||
  reqwest::Client::default()
 | 
			
		||||
    .post(format!("{}/active-gui", service_endpoint))
 | 
			
		||||
    .send()
 | 
			
		||||
    .await?
 | 
			
		||||
    .error_for_status()?;
 | 
			
		||||
 | 
			
		||||
  Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn get_log_file() -> anyhow::Result<PathBuf> {
 | 
			
		||||
  let dirs = ProjectDirs::from("com.yuezk", "GlobalProtect-openconnect", "gpclient")
 | 
			
		||||
    .ok_or_else(|| anyhow::anyhow!("Failed to get project dirs"))?;
 | 
			
		||||
 | 
			
		||||
  fs::create_dir_all(dirs.data_dir())?;
 | 
			
		||||
 | 
			
		||||
  Ok(dirs.data_dir().join("gpclient.log"))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								apps/gpclient/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/gpclient/src/main.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
mod cli;
 | 
			
		||||
mod connect;
 | 
			
		||||
mod disconnect;
 | 
			
		||||
mod launch_gui;
 | 
			
		||||
 | 
			
		||||
pub(crate) const GP_CLIENT_LOCK_FILE: &str = "/var/run/gpclient.lock";
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
async fn main() {
 | 
			
		||||
  cli::run().await;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/gpservice/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/gpservice/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "gpservice"
 | 
			
		||||
version.workspace = true
 | 
			
		||||
edition.workspace = true
 | 
			
		||||
license.workspace = true
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
gpapi = { path = "../../crates/gpapi" }
 | 
			
		||||
openconnect = { path = "../../crates/openconnect" }
 | 
			
		||||
clap.workspace = true
 | 
			
		||||
anyhow.workspace = true
 | 
			
		||||
tokio.workspace = true
 | 
			
		||||
tokio-util.workspace = true
 | 
			
		||||
axum = { workspace = true, features = ["ws"] }
 | 
			
		||||
futures.workspace = true
 | 
			
		||||
serde_json.workspace = true
 | 
			
		||||
env_logger.workspace = true
 | 
			
		||||
log.workspace = true
 | 
			
		||||
compile-time.workspace = true
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/gpservice/com.yuezk.gpservice.policy
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/gpservice/com.yuezk.gpservice.policy
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
<?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>
 | 
			
		||||
							
								
								
									
										182
									
								
								apps/gpservice/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								apps/gpservice/src/cli.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,182 @@
 | 
			
		||||
use std::sync::Arc;
 | 
			
		||||
use std::{collections::HashMap, io::Write};
 | 
			
		||||
 | 
			
		||||
use anyhow::bail;
 | 
			
		||||
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,
 | 
			
		||||
  },
 | 
			
		||||
  GP_SERVICE_LOCK_FILE,
 | 
			
		||||
};
 | 
			
		||||
use log::{info, warn, LevelFilter};
 | 
			
		||||
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!(),
 | 
			
		||||
  ")"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
#[derive(Parser)]
 | 
			
		||||
#[command(version = VERSION)]
 | 
			
		||||
struct Cli {
 | 
			
		||||
  #[clap(long)]
 | 
			
		||||
  minimized: bool,
 | 
			
		||||
  #[clap(long)]
 | 
			
		||||
  env_file: Option<String>,
 | 
			
		||||
  #[cfg(debug_assertions)]
 | 
			
		||||
  #[clap(long)]
 | 
			
		||||
  no_gui: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Cli {
 | 
			
		||||
  async fn run(&mut self, redaction: Arc<Redaction>) -> anyhow::Result<()> {
 | 
			
		||||
    let lock_file = Arc::new(LockFile::new(GP_SERVICE_LOCK_FILE));
 | 
			
		||||
 | 
			
		||||
    if lock_file.check_health().await {
 | 
			
		||||
      bail!("Another instance of the service is already running");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let api_key = self.prepare_api_key();
 | 
			
		||||
 | 
			
		||||
    // Channel for sending requests to the VPN task
 | 
			
		||||
    let (ws_req_tx, ws_req_rx) = mpsc::channel::<WsRequest>(32);
 | 
			
		||||
    // Channel for receiving the VPN state from the VPN task
 | 
			
		||||
    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 (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(4);
 | 
			
		||||
    let shutdown_tx_clone = shutdown_tx.clone();
 | 
			
		||||
    let vpn_task_token = vpn_task.cancel_token();
 | 
			
		||||
    let server_token = ws_server.cancel_token();
 | 
			
		||||
 | 
			
		||||
    let vpn_task_handle = tokio::spawn(async move { vpn_task.start(server_token).await });
 | 
			
		||||
    let ws_server_handle = tokio::spawn(async move { ws_server.start(shutdown_tx_clone).await });
 | 
			
		||||
 | 
			
		||||
    #[cfg(debug_assertions)]
 | 
			
		||||
    let no_gui = self.no_gui;
 | 
			
		||||
 | 
			
		||||
    #[cfg(not(debug_assertions))]
 | 
			
		||||
    let no_gui = false;
 | 
			
		||||
 | 
			
		||||
    if no_gui {
 | 
			
		||||
      info!("GUI is disabled");
 | 
			
		||||
    } else {
 | 
			
		||||
      let envs = self
 | 
			
		||||
        .env_file
 | 
			
		||||
        .as_ref()
 | 
			
		||||
        .map(env_file::load_env_vars)
 | 
			
		||||
        .transpose()?;
 | 
			
		||||
 | 
			
		||||
      let minimized = self.minimized;
 | 
			
		||||
 | 
			
		||||
      tokio::spawn(async move {
 | 
			
		||||
        launch_gui(envs, api_key, minimized).await;
 | 
			
		||||
        let _ = shutdown_tx.send(()).await;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tokio::select! {
 | 
			
		||||
        _ = shutdown_signal() => {
 | 
			
		||||
            info!("Shutdown signal received");
 | 
			
		||||
        }
 | 
			
		||||
        _ = shutdown_rx.recv() => {
 | 
			
		||||
            info!("Shutdown request received, shutting down");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    vpn_task_token.cancel();
 | 
			
		||||
    let _ = tokio::join!(vpn_task_handle, ws_server_handle);
 | 
			
		||||
 | 
			
		||||
    lock_file.unlock()?;
 | 
			
		||||
 | 
			
		||||
    info!("gpservice stopped");
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fn prepare_api_key(&self) -> Vec<u8> {
 | 
			
		||||
    #[cfg(debug_assertions)]
 | 
			
		||||
    if self.no_gui {
 | 
			
		||||
      return gpapi::GP_API_KEY.to_vec();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    generate_key().to_vec()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn init_logger() -> Arc<Redaction> {
 | 
			
		||||
  let redaction = Arc::new(Redaction::new());
 | 
			
		||||
  let redaction_clone = Arc::clone(&redaction);
 | 
			
		||||
  // let target = Box::new(File::create("log.txt").expect("Can't create file"));
 | 
			
		||||
  env_logger::builder()
 | 
			
		||||
    .filter_level(LevelFilter::Info)
 | 
			
		||||
    .format(move |buf, record| {
 | 
			
		||||
      let timestamp = buf.timestamp();
 | 
			
		||||
      writeln!(
 | 
			
		||||
        buf,
 | 
			
		||||
        "[{} {} {}] {}",
 | 
			
		||||
        timestamp,
 | 
			
		||||
        record.level(),
 | 
			
		||||
        record.module_path().unwrap_or_default(),
 | 
			
		||||
        redaction_clone.redact_str(&record.args().to_string())
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
    // .target(env_logger::Target::Pipe(target))
 | 
			
		||||
    .init();
 | 
			
		||||
 | 
			
		||||
  redaction
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn launch_gui(envs: Option<HashMap<String, String>>, api_key: Vec<u8>, mut minimized: bool) {
 | 
			
		||||
  loop {
 | 
			
		||||
    let api_key_clone = api_key.clone();
 | 
			
		||||
    let gui_launcher = GuiLauncher::new()
 | 
			
		||||
      .envs(envs.clone())
 | 
			
		||||
      .api_key(api_key_clone)
 | 
			
		||||
      .minimized(minimized);
 | 
			
		||||
 | 
			
		||||
    match gui_launcher.launch().await {
 | 
			
		||||
      Ok(exit_status) => {
 | 
			
		||||
        // Exit code 99 means that the GUI needs to be restarted
 | 
			
		||||
        if exit_status.code() != Some(99) {
 | 
			
		||||
          info!("GUI exited with code {:?}", exit_status.code());
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        info!("GUI exited with code 99, restarting");
 | 
			
		||||
        minimized = false;
 | 
			
		||||
      }
 | 
			
		||||
      Err(err) => {
 | 
			
		||||
        warn!("Failed to launch GUI: {}", err);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn run() {
 | 
			
		||||
  let mut cli = Cli::parse();
 | 
			
		||||
 | 
			
		||||
  let redaction = init_logger();
 | 
			
		||||
  info!("gpservice started: {}", VERSION);
 | 
			
		||||
 | 
			
		||||
  if let Err(e) = cli.run(redaction).await {
 | 
			
		||||
    eprintln!("Error: {}", e);
 | 
			
		||||
    std::process::exit(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								apps/gpservice/src/handlers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								apps/gpservice/src/handlers.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,94 @@
 | 
			
		||||
use std::{borrow::Cow, ops::ControlFlow, sync::Arc};
 | 
			
		||||
 | 
			
		||||
use axum::{
 | 
			
		||||
  extract::{
 | 
			
		||||
    ws::{self, CloseFrame, Message, WebSocket},
 | 
			
		||||
    State, WebSocketUpgrade,
 | 
			
		||||
  },
 | 
			
		||||
  response::IntoResponse,
 | 
			
		||||
};
 | 
			
		||||
use futures::{SinkExt, StreamExt};
 | 
			
		||||
use gpapi::service::event::WsEvent;
 | 
			
		||||
use log::{info, warn};
 | 
			
		||||
 | 
			
		||||
use crate::ws_server::WsServerContext;
 | 
			
		||||
 | 
			
		||||
pub(crate) async fn health() -> impl IntoResponse {
 | 
			
		||||
  "OK"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) async fn active_gui(State(ctx): State<Arc<WsServerContext>>) -> impl IntoResponse {
 | 
			
		||||
  ctx.send_event(WsEvent::ActiveGui).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))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn handle_socket(mut socket: WebSocket, ctx: Arc<WsServerContext>) {
 | 
			
		||||
  // Send ping message
 | 
			
		||||
  if let Err(err) = socket.send(Message::Ping("Hi".into())).await {
 | 
			
		||||
    warn!("Failed to send ping: {}", err);
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Wait for pong message
 | 
			
		||||
  if socket.recv().await.is_none() {
 | 
			
		||||
    warn!("Failed to receive pong");
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  info!("New client connected");
 | 
			
		||||
 | 
			
		||||
  let (mut sender, mut receiver) = socket.split();
 | 
			
		||||
  let (connection, mut msg_rx) = ctx.add_connection().await;
 | 
			
		||||
 | 
			
		||||
  let send_task = tokio::spawn(async move {
 | 
			
		||||
    while let Some(msg) = msg_rx.recv().await {
 | 
			
		||||
      if let Err(err) = sender.send(msg).await {
 | 
			
		||||
        info!("Failed to send message: {}", err);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let close_msg = Message::Close(Some(CloseFrame {
 | 
			
		||||
      code: ws::close_code::NORMAL,
 | 
			
		||||
      reason: Cow::from("Goodbye"),
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    if let Err(err) = sender.send(close_msg).await {
 | 
			
		||||
      warn!("Failed to close socket: {}", err);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let conn = Arc::clone(&connection);
 | 
			
		||||
  let ctx_clone = Arc::clone(&ctx);
 | 
			
		||||
  let recv_task = tokio::spawn(async move {
 | 
			
		||||
    while let Some(Ok(msg)) = receiver.next().await {
 | 
			
		||||
      let ControlFlow::Continue(ws_req) = conn.recv_msg(msg) else {
 | 
			
		||||
        break;
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      if let Err(err) = ctx_clone.forward_req(ws_req).await {
 | 
			
		||||
        info!("Failed to forward request: {}", err);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  tokio::select! {
 | 
			
		||||
    _ = send_task => {
 | 
			
		||||
        info!("WS server send task completed");
 | 
			
		||||
    },
 | 
			
		||||
    _ = recv_task => {
 | 
			
		||||
        info!("WS server recv task completed");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  info!("Client disconnected");
 | 
			
		||||
 | 
			
		||||
  ctx.remove_connection(connection).await;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								apps/gpservice/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/gpservice/src/main.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
mod cli;
 | 
			
		||||
mod handlers;
 | 
			
		||||
mod routes;
 | 
			
		||||
mod vpn_task;
 | 
			
		||||
mod ws_server;
 | 
			
		||||
mod ws_connection;
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
async fn main() {
 | 
			
		||||
  cli::run().await;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								apps/gpservice/src/routes.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/gpservice/src/routes.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
use std::sync::Arc;
 | 
			
		||||
 | 
			
		||||
use axum::{routing::{get, post}, Router};
 | 
			
		||||
 | 
			
		||||
use crate::{handlers, ws_server::WsServerContext};
 | 
			
		||||
 | 
			
		||||
pub(crate) fn routes(ctx: Arc<WsServerContext>) -> Router {
 | 
			
		||||
  Router::new()
 | 
			
		||||
    .route("/health", get(handlers::health))
 | 
			
		||||
    .route("/active-gui", post(handlers::active_gui))
 | 
			
		||||
    .route("/ws", get(handlers::ws_handler))
 | 
			
		||||
    .with_state(ctx)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										144
									
								
								apps/gpservice/src/vpn_task.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								apps/gpservice/src/vpn_task.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,144 @@
 | 
			
		||||
use std::{sync::Arc, thread};
 | 
			
		||||
 | 
			
		||||
use gpapi::service::{
 | 
			
		||||
  request::{ConnectRequest, WsRequest},
 | 
			
		||||
  vpn_state::VpnState,
 | 
			
		||||
};
 | 
			
		||||
use log::info;
 | 
			
		||||
use openconnect::Vpn;
 | 
			
		||||
use tokio::sync::{mpsc, oneshot, watch, RwLock};
 | 
			
		||||
use tokio_util::sync::CancellationToken;
 | 
			
		||||
 | 
			
		||||
pub(crate) struct VpnTaskContext {
 | 
			
		||||
  vpn_handle: Arc<RwLock<Option<Vpn>>>,
 | 
			
		||||
  vpn_state_tx: Arc<watch::Sender<VpnState>>,
 | 
			
		||||
  disconnect_rx: RwLock<Option<oneshot::Receiver<()>>>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl VpnTaskContext {
 | 
			
		||||
  pub fn new(vpn_state_tx: watch::Sender<VpnState>) -> Self {
 | 
			
		||||
    Self {
 | 
			
		||||
      vpn_handle: Default::default(),
 | 
			
		||||
      vpn_state_tx: Arc::new(vpn_state_tx),
 | 
			
		||||
      disconnect_rx: Default::default(),
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn connect(&self, req: ConnectRequest) {
 | 
			
		||||
    let vpn_state = self.vpn_state_tx.borrow().clone();
 | 
			
		||||
    if !matches!(vpn_state, VpnState::Disconnected) {
 | 
			
		||||
      info!("VPN is not disconnected, ignore the request");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let info = req.info().clone();
 | 
			
		||||
    let vpn_handle = self.vpn_handle.clone();
 | 
			
		||||
    let args = req.args();
 | 
			
		||||
    let vpn = Vpn::builder(req.gateway().server(), args.cookie())
 | 
			
		||||
      .user_agent(args.user_agent())
 | 
			
		||||
      .script(args.vpnc_script())
 | 
			
		||||
      .os(args.openconnect_os())
 | 
			
		||||
      .build();
 | 
			
		||||
 | 
			
		||||
    // Save the VPN handle
 | 
			
		||||
    vpn_handle.write().await.replace(vpn);
 | 
			
		||||
 | 
			
		||||
    let vpn_state_tx = self.vpn_state_tx.clone();
 | 
			
		||||
    let connect_info = Box::new(info.clone());
 | 
			
		||||
    vpn_state_tx.send(VpnState::Connecting(connect_info)).ok();
 | 
			
		||||
 | 
			
		||||
    let (disconnect_tx, disconnect_rx) = oneshot::channel::<()>();
 | 
			
		||||
    self.disconnect_rx.write().await.replace(disconnect_rx);
 | 
			
		||||
 | 
			
		||||
    // Spawn a new thread to process the VPN connection, cannot use tokio::spawn here.
 | 
			
		||||
    // Otherwise, it will block the tokio runtime and cannot send the VPN state to the channel
 | 
			
		||||
    thread::spawn(move || {
 | 
			
		||||
      let vpn_state_tx_clone = vpn_state_tx.clone();
 | 
			
		||||
 | 
			
		||||
      vpn_handle.blocking_read().as_ref().map(|vpn| {
 | 
			
		||||
        vpn.connect(move || {
 | 
			
		||||
          let connect_info = Box::new(info.clone());
 | 
			
		||||
          vpn_state_tx.send(VpnState::Connected(connect_info)).ok();
 | 
			
		||||
        })
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Notify the VPN is disconnected
 | 
			
		||||
      vpn_state_tx_clone.send(VpnState::Disconnected).ok();
 | 
			
		||||
      // Remove the VPN handle
 | 
			
		||||
      vpn_handle.blocking_write().take();
 | 
			
		||||
 | 
			
		||||
      disconnect_tx.send(()).ok();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn disconnect(&self) {
 | 
			
		||||
    if let Some(disconnect_rx) = self.disconnect_rx.write().await.take() {
 | 
			
		||||
      if let Some(vpn) = self.vpn_handle.read().await.as_ref() {
 | 
			
		||||
        self.vpn_state_tx.send(VpnState::Disconnecting).ok();
 | 
			
		||||
        vpn.disconnect()
 | 
			
		||||
      }
 | 
			
		||||
      // Wait for the VPN to be disconnected
 | 
			
		||||
      disconnect_rx.await.ok();
 | 
			
		||||
      info!("VPN disconnected");
 | 
			
		||||
    } else {
 | 
			
		||||
      info!("VPN is not connected, skip disconnect");
 | 
			
		||||
      self.vpn_state_tx.send(VpnState::Disconnected).ok();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) struct VpnTask {
 | 
			
		||||
  ws_req_rx: mpsc::Receiver<WsRequest>,
 | 
			
		||||
  ctx: Arc<VpnTaskContext>,
 | 
			
		||||
  cancel_token: CancellationToken,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl VpnTask {
 | 
			
		||||
  pub fn new(ws_req_rx: mpsc::Receiver<WsRequest>, vpn_state_tx: watch::Sender<VpnState>) -> Self {
 | 
			
		||||
    let ctx = Arc::new(VpnTaskContext::new(vpn_state_tx));
 | 
			
		||||
    let cancel_token = CancellationToken::new();
 | 
			
		||||
 | 
			
		||||
    Self {
 | 
			
		||||
      ws_req_rx,
 | 
			
		||||
      ctx,
 | 
			
		||||
      cancel_token,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn cancel_token(&self) -> CancellationToken {
 | 
			
		||||
    self.cancel_token.clone()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn start(&mut self, server_cancel_token: CancellationToken) {
 | 
			
		||||
    let cancel_token = self.cancel_token.clone();
 | 
			
		||||
 | 
			
		||||
    tokio::select! {
 | 
			
		||||
        _ = self.recv() => {
 | 
			
		||||
            info!("VPN task stopped");
 | 
			
		||||
        }
 | 
			
		||||
        _ = cancel_token.cancelled() => {
 | 
			
		||||
            info!("VPN task cancelled");
 | 
			
		||||
            self.ctx.disconnect().await;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    server_cancel_token.cancel();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async fn recv(&mut self) {
 | 
			
		||||
    while let Some(req) = self.ws_req_rx.recv().await {
 | 
			
		||||
      tokio::spawn(process_ws_req(req, self.ctx.clone()));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn process_ws_req(req: WsRequest, ctx: Arc<VpnTaskContext>) {
 | 
			
		||||
  match req {
 | 
			
		||||
    WsRequest::Connect(req) => {
 | 
			
		||||
      ctx.connect(*req).await;
 | 
			
		||||
    }
 | 
			
		||||
    WsRequest::Disconnect(_) => {
 | 
			
		||||
      ctx.disconnect().await;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								apps/gpservice/src/ws_connection.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								apps/gpservice/src/ws_connection.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
use std::{ops::ControlFlow, sync::Arc};
 | 
			
		||||
 | 
			
		||||
use axum::extract::ws::{CloseFrame, Message};
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  service::{event::WsEvent, request::WsRequest},
 | 
			
		||||
  utils::crypto::Crypto,
 | 
			
		||||
};
 | 
			
		||||
use log::{info, warn};
 | 
			
		||||
use tokio::sync::mpsc;
 | 
			
		||||
 | 
			
		||||
pub(crate) struct WsConnection {
 | 
			
		||||
  crypto: Arc<Crypto>,
 | 
			
		||||
  tx: mpsc::Sender<Message>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl WsConnection {
 | 
			
		||||
  pub fn new(crypto: Arc<Crypto>, tx: mpsc::Sender<Message>) -> Self {
 | 
			
		||||
    Self { crypto, tx }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn send_event(&self, event: &WsEvent) -> anyhow::Result<()> {
 | 
			
		||||
    let encrypted = self.crypto.encrypt(event)?;
 | 
			
		||||
    let msg = Message::Binary(encrypted);
 | 
			
		||||
 | 
			
		||||
    self.tx.send(msg).await?;
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn recv_msg(&self, msg: Message) -> ControlFlow<(), WsRequest> {
 | 
			
		||||
    match msg {
 | 
			
		||||
      Message::Binary(data) => match self.crypto.decrypt(data) {
 | 
			
		||||
        Ok(ws_req) => ControlFlow::Continue(ws_req),
 | 
			
		||||
        Err(err) => {
 | 
			
		||||
          info!("Failed to decrypt message: {}", err);
 | 
			
		||||
          ControlFlow::Break(())
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      Message::Close(cf) => {
 | 
			
		||||
        if let Some(CloseFrame { code, reason }) = cf {
 | 
			
		||||
          info!("Client sent close, code {} and reason `{}`", code, reason);
 | 
			
		||||
        } else {
 | 
			
		||||
          info!("Client somehow sent close message without CloseFrame");
 | 
			
		||||
        }
 | 
			
		||||
        ControlFlow::Break(())
 | 
			
		||||
      }
 | 
			
		||||
      _ => {
 | 
			
		||||
        warn!("WS server received unexpected message: {:?}", msg);
 | 
			
		||||
        ControlFlow::Break(())
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										158
									
								
								apps/gpservice/src/ws_server.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								apps/gpservice/src/ws_server.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,158 @@
 | 
			
		||||
use std::sync::Arc;
 | 
			
		||||
 | 
			
		||||
use axum::extract::ws::Message;
 | 
			
		||||
use gpapi::{
 | 
			
		||||
  service::{event::WsEvent, request::WsRequest, vpn_state::VpnState},
 | 
			
		||||
  utils::{crypto::Crypto, lock_file::LockFile, redact::Redaction},
 | 
			
		||||
};
 | 
			
		||||
use log::{info, warn};
 | 
			
		||||
use tokio::{
 | 
			
		||||
  net::TcpListener,
 | 
			
		||||
  sync::{mpsc, watch, RwLock},
 | 
			
		||||
};
 | 
			
		||||
use tokio_util::sync::CancellationToken;
 | 
			
		||||
 | 
			
		||||
use crate::{routes, ws_connection::WsConnection};
 | 
			
		||||
 | 
			
		||||
pub(crate) struct WsServerContext {
 | 
			
		||||
  crypto: Arc<Crypto>,
 | 
			
		||||
  ws_req_tx: mpsc::Sender<WsRequest>,
 | 
			
		||||
  vpn_state_rx: watch::Receiver<VpnState>,
 | 
			
		||||
  redaction: Arc<Redaction>,
 | 
			
		||||
  connections: RwLock<Vec<Arc<WsConnection>>>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl WsServerContext {
 | 
			
		||||
  pub fn new(
 | 
			
		||||
    api_key: Vec<u8>,
 | 
			
		||||
    ws_req_tx: mpsc::Sender<WsRequest>,
 | 
			
		||||
    vpn_state_rx: watch::Receiver<VpnState>,
 | 
			
		||||
    redaction: Arc<Redaction>,
 | 
			
		||||
  ) -> Self {
 | 
			
		||||
    Self {
 | 
			
		||||
      crypto: Arc::new(Crypto::new(api_key)),
 | 
			
		||||
      ws_req_tx,
 | 
			
		||||
      vpn_state_rx,
 | 
			
		||||
      redaction,
 | 
			
		||||
      connections: Default::default(),
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn send_event(&self, event: WsEvent) {
 | 
			
		||||
    let connections = self.connections.read().await;
 | 
			
		||||
 | 
			
		||||
    for conn in connections.iter() {
 | 
			
		||||
      let _ = conn.send_event(&event).await;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn add_connection(&self) -> (Arc<WsConnection>, mpsc::Receiver<Message>) {
 | 
			
		||||
    let (tx, rx) = mpsc::channel::<Message>(32);
 | 
			
		||||
    let conn = Arc::new(WsConnection::new(Arc::clone(&self.crypto), tx));
 | 
			
		||||
 | 
			
		||||
    // Send current VPN state to new client
 | 
			
		||||
    info!("Sending current VPN state to new client");
 | 
			
		||||
    let vpn_state = self.vpn_state_rx.borrow().clone();
 | 
			
		||||
    if let Err(err) = conn.send_event(&WsEvent::VpnState(vpn_state)).await {
 | 
			
		||||
      warn!("Failed to send VPN state to new client: {}", err);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    self.connections.write().await.push(Arc::clone(&conn));
 | 
			
		||||
 | 
			
		||||
    (conn, rx)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn remove_connection(&self, conn: Arc<WsConnection>) {
 | 
			
		||||
    let mut connections = self.connections.write().await;
 | 
			
		||||
    connections.retain(|c| !Arc::ptr_eq(c, &conn));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fn vpn_state_rx(&self) -> watch::Receiver<VpnState> {
 | 
			
		||||
    self.vpn_state_rx.clone()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn forward_req(&self, req: WsRequest) -> anyhow::Result<()> {
 | 
			
		||||
    if let WsRequest::Connect(ref req) = req {
 | 
			
		||||
      self
 | 
			
		||||
        .redaction
 | 
			
		||||
        .add_values(&[req.gateway().server(), req.args().cookie()])?
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    self.ws_req_tx.send(req).await?;
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub(crate) struct WsServer {
 | 
			
		||||
  ctx: Arc<WsServerContext>,
 | 
			
		||||
  cancel_token: CancellationToken,
 | 
			
		||||
  lock_file: Arc<LockFile>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl WsServer {
 | 
			
		||||
  pub fn new(
 | 
			
		||||
    api_key: Vec<u8>,
 | 
			
		||||
    ws_req_tx: mpsc::Sender<WsRequest>,
 | 
			
		||||
    vpn_state_rx: watch::Receiver<VpnState>,
 | 
			
		||||
    lock_file: Arc<LockFile>,
 | 
			
		||||
    redaction: Arc<Redaction>,
 | 
			
		||||
  ) -> Self {
 | 
			
		||||
    let ctx = Arc::new(WsServerContext::new(
 | 
			
		||||
      api_key,
 | 
			
		||||
      ws_req_tx,
 | 
			
		||||
      vpn_state_rx,
 | 
			
		||||
      redaction,
 | 
			
		||||
    ));
 | 
			
		||||
    let cancel_token = CancellationToken::new();
 | 
			
		||||
 | 
			
		||||
    Self {
 | 
			
		||||
      ctx,
 | 
			
		||||
      cancel_token,
 | 
			
		||||
      lock_file,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub fn cancel_token(&self) -> CancellationToken {
 | 
			
		||||
    self.cancel_token.clone()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pub async fn start(&self, shutdown_tx: mpsc::Sender<()>) {
 | 
			
		||||
    if let Ok(listener) = TcpListener::bind("127.0.0.1:0").await {
 | 
			
		||||
      let local_addr = listener.local_addr().unwrap();
 | 
			
		||||
 | 
			
		||||
      self.lock_file.lock(local_addr.port().to_string()).unwrap();
 | 
			
		||||
 | 
			
		||||
      info!("WS server listening on port: {}", local_addr.port());
 | 
			
		||||
 | 
			
		||||
      tokio::select! {
 | 
			
		||||
        _ = watch_vpn_state(self.ctx.vpn_state_rx(), Arc::clone(&self.ctx)) => {
 | 
			
		||||
          info!("VPN state watch task completed");
 | 
			
		||||
        }
 | 
			
		||||
        _ = start_server(listener, self.ctx.clone()) => {
 | 
			
		||||
            info!("WS server stopped");
 | 
			
		||||
        }
 | 
			
		||||
        _ = self.cancel_token.cancelled() => {
 | 
			
		||||
          info!("WS server cancelled");
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let _ = shutdown_tx.send(()).await;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn watch_vpn_state(mut vpn_state_rx: watch::Receiver<VpnState>, ctx: Arc<WsServerContext>) {
 | 
			
		||||
  while vpn_state_rx.changed().await.is_ok() {
 | 
			
		||||
    let vpn_state = vpn_state_rx.borrow().clone();
 | 
			
		||||
    ctx.send_event(WsEvent::VpnState(vpn_state)).await;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn start_server(listener: TcpListener, ctx: Arc<WsServerContext>) -> anyhow::Result<()> {
 | 
			
		||||
  let routes = routes::routes(ctx);
 | 
			
		||||
 | 
			
		||||
  axum::serve(listener, routes).await?;
 | 
			
		||||
 | 
			
		||||
  Ok(())
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user