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