mirror of
				https://github.com/yuezk/GlobalProtect-openconnect.git
				synced 2025-05-20 07:26:58 -04:00 
			
		
		
		
	Refactor using Tauri (#278)
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