feat(tauri): support self-signed remote HTTPS via server-backed proxy (#333)

## Summary

- add a server-backed HTTPS proxy flow for Tauri remote windows so
self-signed remote HTTPS works with the local CLI TLS assets and desktop
auth/cookie handling
- manage remote proxy sessions through `packages/server` with
per-session bootstrap, local-only cleanup, and explicit session
lifecycle handling
- support the Tauri desktop flow across environments, including packaged
Windows builds, `tauri dev`, and updated Linux/macOS handling for the
new local HTTPS proxy path

## Testing

- `npm run build --workspace @neuralnomads/codenomad`
- `cargo check`
- `npm run build --workspace @codenomad/tauri-app`
- Windows smoke test for concurrent remote proxy bootstrap sessions
- Windows manual validation of packaged Tauri remote connection flow

## Notes

- Windows was validated end-to-end.
- Linux and macOS code paths were updated for the new proxy flow, but
runtime validation on those platforms is still pending.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
Pascal André
2026-04-20 00:26:55 +02:00
committed by GitHub
parent 623a09fd7e
commit 04fc28c492
20 changed files with 4921 additions and 88 deletions

View File

@@ -1,12 +1,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
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};
@@ -16,6 +20,7 @@ use tauri::webview::Webview;
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -45,6 +50,9 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
@@ -53,9 +61,87 @@ struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
let mut dialog = app
.dialog()
.message(
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
)
.title("Install Local Certificate")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"Continue".into(),
"Cancel".into(),
));
if let Some(window) = app.get_webview_window("main") {
dialog = dialog.parent(&window);
}
dialog.show(move |accepted| {
let _ = sender.send(accepted);
});
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
.await
.map_err(|err| err.to_string())
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
@@ -119,7 +205,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"tauri" | "asset" | "file" | "about" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -167,25 +253,61 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
);
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.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(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.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();
@@ -193,25 +315,51 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
return Ok(());
}
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
&window_url,
allow_linux_tls_certificate,
) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[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| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label);
origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
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);
}
}
});
@@ -219,6 +367,40 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
Ok(())
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
})? {
let accepted = confirm_local_certificate_install(&app).await?;
if !accepted {
return Err(
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
.to_string(),
);
}
}
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -346,6 +528,8 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -373,6 +557,9 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();