fix(desktop): allow self-signed remote HTTPS on Linux

Use WebKitGTK TLS exceptions for remote windows so Linux no longer depends on system CA installation or sudo-managed trust stores.
This commit is contained in:
pascalandr
2026-04-18 09:30:38 +02:00
parent adcaf3a116
commit be4f383602
5 changed files with 192 additions and 40 deletions

View File

@@ -612,6 +612,7 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]

View File

@@ -38,3 +38,6 @@ tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"

View File

@@ -101,11 +101,20 @@ pub fn ensure_local_cert() -> Result<LocalCert, String> {
})
}
/// Returns true if the certificate has already been added to the Windows trust
/// store (indicated by the `.trusted` marker file).
fn trusted_marker_path() -> Result<PathBuf, String> {
Ok(cert_dir()?.join(TRUSTED_MARKER))
}
fn write_trusted_marker() -> Result<(), String> {
fs::write(trusted_marker_path()?, "trusted")
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
/// Returns true if the certificate has already been added to the OS trust store
/// (indicated by the `.trusted` marker file).
pub fn is_cert_trusted() -> bool {
cert_dir()
.map(|dir| dir.join(TRUSTED_MARKER).exists())
trusted_marker_path()
.map(|path| path.exists())
.unwrap_or(false)
}
@@ -151,17 +160,12 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
}
}
// Write marker file
let dir = cert_dir()?;
fs::write(dir.join(TRUSTED_MARKER), "trusted")
.map_err(|e| format!("Failed to write trust marker: {e}"))?;
write_trusted_marker()?;
Ok(())
}
#[cfg(not(windows))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// On non-Windows platforms, certificate trust is not yet implemented.
// The proxy will still work but the browser may show a warning.
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}

View File

@@ -0,0 +1,88 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
skip_tls_verify && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,15 +1,21 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg_attr(target_os = "linux", allow(dead_code))]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
#[cfg_attr(target_os = "linux", allow(dead_code))]
mod remote_proxy;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use remote_proxy::{start_remote_proxy, ProxyTlsConfig, RemoteProxyHandle};
#[cfg(not(target_os = "linux"))]
use remote_proxy::start_remote_proxy;
use remote_proxy::{ProxyTlsConfig, RemoteProxyHandle};
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -48,6 +54,8 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
pub remote_proxies: Mutex<HashMap<String, RemoteProxyHandle>>,
}
@@ -174,7 +182,7 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
tls_config: Option<ProxyTlsConfig>,
_tls_config: Option<ProxyTlsConfig>,
) -> Result<(), String> {
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
@@ -184,41 +192,55 @@ async fn open_remote_window_impl(
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
let state = app.state::<AppState>();
let reuses_existing_proxy = {
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
proxies
.get(&label)
.map(|existing| existing.matches(&parsed, payload.skip_tls_verify))
.unwrap_or(false)
};
#[cfg(target_os = "linux")]
let window_url = parsed.clone();
let local_url = if reuses_existing_proxy {
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
proxies
.get(&label)
.map(|handle| handle.entry_url().clone())
.ok_or_else(|| "Remote proxy disappeared before reuse".to_string())?
} else {
let new_proxy =
start_remote_proxy(parsed.clone(), payload.skip_tls_verify, tls_config).await?;
let local_url = new_proxy.entry_url().clone();
let mut proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
if let Some(existing) = proxies.remove(&label) {
existing.shutdown();
#[cfg(not(target_os = "linux"))]
let window_url = {
let state = app.state::<AppState>();
let reuses_existing_proxy = {
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
proxies
.get(&label)
.map(|existing| existing.matches(&parsed, payload.skip_tls_verify))
.unwrap_or(false)
};
if reuses_existing_proxy {
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
proxies
.get(&label)
.map(|handle| handle.entry_url().clone())
.ok_or_else(|| "Remote proxy disappeared before reuse".to_string())?
} else {
let new_proxy =
start_remote_proxy(parsed.clone(), payload.skip_tls_verify, _tls_config).await?;
let local_url = new_proxy.entry_url().clone();
let mut proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
if let Some(existing) = proxies.remove(&label) {
existing.shutdown();
}
proxies.insert(label.clone(), new_proxy);
local_url
}
proxies.insert(label.clone(), new_proxy);
local_url
};
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), local_url.origin().ascii_serialization());
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), payload.skip_tls_verify);
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(local_url.clone());
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
@@ -226,13 +248,32 @@ async fn open_remote_window_impl(
return Ok(());
}
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(local_url))
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
{
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
@@ -240,6 +281,12 @@ async fn open_remote_window_impl(
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
}
if let Ok(mut proxies) = app_handle.state::<AppState>().remote_proxies.lock() {
if let Some(handle) = proxies.remove(&label_for_cleanup) {
handle.shutdown();
@@ -253,6 +300,12 @@ async fn open_remote_window_impl(
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
return open_remote_window_impl(app, payload, None).await;
}
#[cfg(not(target_os = "linux"))]
let tls_config = match cert_manager::ensure_local_cert() {
Ok(local_cert) => {
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.cert_der) {
@@ -269,6 +322,7 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
}
};
#[cfg(not(target_os = "linux"))]
open_remote_window_impl(app, payload, tls_config).await
}
@@ -428,6 +482,8 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
remote_proxies: Mutex::new(HashMap::new()),
})
.setup(|app| {