fix(desktop): support self-signed remote HTTPS windows
Route remote windows through a trusted local HTTPS proxy so WebView2 accepts secure cookies with self-signed servers. Preserve remote base paths, rewrite origin headers for proxied requests, and keep the certificate helper buildable outside Windows.
This commit is contained in:
@@ -12,11 +12,20 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
axum = "0.7"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
bytes = "1"
|
||||
futures-util = "0.3"
|
||||
rcgen = "0.13"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
@@ -28,4 +37,4 @@ url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
167
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
167
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use rcgen::{CertificateParams, DnType, KeyPair, SanType};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
const CERT_DIR_NAME: &str = "proxy-certs";
|
||||
const CERT_PEM_FILE: &str = "proxy.crt";
|
||||
const KEY_PEM_FILE: &str = "proxy.key";
|
||||
const CERT_DER_FILE: &str = "proxy.der";
|
||||
const TRUSTED_MARKER: &str = ".trusted";
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
/// Holds PEM-encoded certificate and private key for the local HTTPS proxy.
|
||||
pub struct LocalCert {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
pub cert_der: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Returns the directory where proxy certificates are stored.
|
||||
fn cert_dir() -> Result<PathBuf, String> {
|
||||
let base = dirs::data_local_dir()
|
||||
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(CERT_DIR_NAME));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(base.join("codenomad").join(CERT_DIR_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures a self-signed certificate exists on disk, generating one if needed.
|
||||
/// Returns the PEM cert, PEM key, and DER cert bytes.
|
||||
pub fn ensure_local_cert() -> Result<LocalCert, String> {
|
||||
let dir = cert_dir()?;
|
||||
let cert_pem_path = dir.join(CERT_PEM_FILE);
|
||||
let key_pem_path = dir.join(KEY_PEM_FILE);
|
||||
let cert_der_path = dir.join(CERT_DER_FILE);
|
||||
|
||||
// If all files exist, load from disk
|
||||
if cert_pem_path.exists() && key_pem_path.exists() && cert_der_path.exists() {
|
||||
let cert_pem = fs::read_to_string(&cert_pem_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", cert_pem_path.display()))?;
|
||||
let key_pem = fs::read_to_string(&key_pem_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", key_pem_path.display()))?;
|
||||
let cert_der = fs::read(&cert_der_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", cert_der_path.display()))?;
|
||||
return Ok(LocalCert {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
cert_der,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new self-signed certificate
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create cert dir {}: {e}", dir.display()))?;
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "CodeNomad Local Proxy");
|
||||
params.subject_alt_names = vec![
|
||||
SanType::DnsName("localhost".try_into().map_err(|e| format!("{e}"))?),
|
||||
SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
];
|
||||
|
||||
// Valid for 10 years
|
||||
params.not_before = rcgen::date_time_ymd(2024, 1, 1);
|
||||
let ten_years = Duration::from_secs(10 * 365 * 24 * 3600);
|
||||
params.not_after = params.not_before + ten_years;
|
||||
|
||||
let key_pair = KeyPair::generate().map_err(|e| format!("Key generation failed: {e}"))?;
|
||||
let cert = params
|
||||
.self_signed(&key_pair)
|
||||
.map_err(|e| format!("Certificate generation failed: {e}"))?;
|
||||
|
||||
let cert_pem = cert.pem();
|
||||
let key_pem = key_pair.serialize_pem();
|
||||
let cert_der = cert.der().to_vec();
|
||||
|
||||
fs::write(&cert_pem_path, &cert_pem)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", cert_pem_path.display()))?;
|
||||
fs::write(&key_pem_path, &key_pem)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", key_pem_path.display()))?;
|
||||
fs::write(&cert_der_path, &cert_der)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", cert_der_path.display()))?;
|
||||
|
||||
// Remove the trusted marker since this is a new cert
|
||||
let marker = dir.join(TRUSTED_MARKER);
|
||||
let _ = fs::remove_file(&marker);
|
||||
|
||||
Ok(LocalCert {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
cert_der,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if the certificate has already been added to the Windows trust
|
||||
/// store (indicated by the `.trusted` marker file).
|
||||
pub fn is_cert_trusted() -> bool {
|
||||
cert_dir()
|
||||
.map(|dir| dir.join(TRUSTED_MARKER).exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Adds the DER-encoded certificate to the Windows `CurrentUser\Root` store.
|
||||
/// This will show a one-time Windows security confirmation dialog.
|
||||
/// After success, writes a `.trusted` marker file to avoid re-prompting.
|
||||
#[cfg(windows)]
|
||||
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||
};
|
||||
|
||||
if is_cert_trusted() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// "Root" in UTF-16
|
||||
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
|
||||
|
||||
unsafe {
|
||||
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
|
||||
if store.is_null() {
|
||||
return Err("Failed to open CurrentUser\\Root certificate store".into());
|
||||
}
|
||||
|
||||
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
|
||||
let result = CertAddEncodedCertificateToStore(
|
||||
store,
|
||||
encoding,
|
||||
cert_der.as_ptr(),
|
||||
cert_der.len() as u32,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
|
||||
CertCloseStore(store, 0);
|
||||
|
||||
if result == 0 {
|
||||
return Err("Failed to add certificate to trust store. \
|
||||
The user may have declined the security dialog."
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Write marker file
|
||||
let dir = cert_dir()?;
|
||||
fs::write(dir.join(TRUSTED_MARKER), "trusted")
|
||||
.map_err(|e| format!("Failed to write trust marker: {e}"))?;
|
||||
|
||||
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.
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod cert_manager;
|
||||
mod cli_manager;
|
||||
mod remote_proxy;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use remote_proxy::{start_remote_proxy, ProxyTlsConfig, RemoteProxyHandle};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -45,6 +48,7 @@ pub struct AppState {
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_proxies: Mutex<HashMap<String, RemoteProxyHandle>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -119,7 +123,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,15 +171,11 @@ 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(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn open_remote_window_impl(
|
||||
app: AppHandle,
|
||||
payload: RemoteWindowPayload,
|
||||
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);
|
||||
let title = format!(
|
||||
@@ -184,8 +184,41 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
|
||||
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)
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
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());
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
let _ = existing.navigate(local_url.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
@@ -193,25 +226,24 @@ 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());
|
||||
|
||||
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())?;
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(local_url))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
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 proxies) = app_handle.state::<AppState>().remote_proxies.lock() {
|
||||
if let Some(handle) = proxies.remove(&label_for_cleanup) {
|
||||
handle.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -219,6 +251,27 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
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) {
|
||||
eprintln!("[tauri] failed to trust proxy cert: {err}");
|
||||
}
|
||||
Some(ProxyTlsConfig {
|
||||
cert_pem: local_cert.cert_pem,
|
||||
key_pem: local_cert.key_pem,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[tauri] failed to generate proxy cert, falling back to HTTP: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
open_remote_window_impl(app, payload, tls_config).await
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
@@ -346,6 +399,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 +428,7 @@ fn main() {
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
remote_proxies: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
|
||||
382
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
382
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use futures_util::TryStreamExt;
|
||||
use rand::RngCore;
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_TOKEN_QUERY: &str = "proxy_token";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
client: Client,
|
||||
target_base_url: Url,
|
||||
local_base_url: Url,
|
||||
session_token: String,
|
||||
session_activated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// TLS configuration for the local HTTPS proxy.
|
||||
pub struct ProxyTlsConfig {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
pub struct RemoteProxyHandle {
|
||||
local_base_url: Url,
|
||||
entry_url: Url,
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
server_handle: axum_server::Handle,
|
||||
}
|
||||
|
||||
impl RemoteProxyHandle {
|
||||
pub fn local_base_url(&self) -> &Url {
|
||||
&self.local_base_url
|
||||
}
|
||||
|
||||
pub fn entry_url(&self) -> &Url {
|
||||
&self.entry_url
|
||||
}
|
||||
|
||||
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.server_handle.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteProxyHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_remote_proxy(
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
tls_config: Option<ProxyTlsConfig>,
|
||||
) -> Result<RemoteProxyHandle, String> {
|
||||
let client = Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.danger_accept_invalid_certs(skip_tls_verify)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Pre-bind a std TcpListener on port 0 to discover the actual port
|
||||
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|err| err.to_string())?;
|
||||
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
|
||||
|
||||
let scheme = if tls_config.is_some() { "https" } else { "http" };
|
||||
let local_base_url =
|
||||
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
|
||||
let session_token = generate_session_token();
|
||||
let mut entry_url = local_base_url.clone();
|
||||
entry_url.set_path(target_base_url.path());
|
||||
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
|
||||
|
||||
let state = Arc::new(ProxyState {
|
||||
client,
|
||||
target_base_url: target_base_url.clone(),
|
||||
local_base_url: local_base_url.clone(),
|
||||
session_token,
|
||||
session_activated: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/*path", any(proxy_request))
|
||||
.route("/", any(proxy_request))
|
||||
.with_state(state);
|
||||
|
||||
let server_handle = axum_server::Handle::new();
|
||||
let handle_clone = server_handle.clone();
|
||||
|
||||
if let Some(tls) = tls_config {
|
||||
let rustls_config =
|
||||
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp(std_listener)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteProxyHandle {
|
||||
local_base_url,
|
||||
entry_url,
|
||||
target_base_url,
|
||||
skip_tls_verify,
|
||||
server_handle,
|
||||
})
|
||||
}
|
||||
|
||||
async fn proxy_request(
|
||||
State(state): State<Arc<ProxyState>>,
|
||||
request: Request,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
if !state.session_activated.load(Ordering::SeqCst) {
|
||||
if request_bootstraps_session(&request, &state.session_token) {
|
||||
state.session_activated.store(true, Ordering::SeqCst);
|
||||
return Ok(build_bootstrap_response(request.uri())?);
|
||||
}
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut builder = state
|
||||
.client
|
||||
.request(request.method().clone(), upstream_url.clone());
|
||||
builder = builder.headers(filter_request_headers(
|
||||
request.headers(),
|
||||
&state.target_base_url,
|
||||
)?);
|
||||
|
||||
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let upstream = builder.send().await.map_err(map_upstream_error)?;
|
||||
let status = upstream.status();
|
||||
let headers = rewrite_response_headers(
|
||||
upstream.headers(),
|
||||
&state.target_base_url,
|
||||
&state.local_base_url,
|
||||
)?;
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::other(err.to_string()));
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
|
||||
let mut url = base_url.clone();
|
||||
url.set_path(uri.path());
|
||||
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0_u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
|
||||
request.uri().query().is_some_and(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
|
||||
let redirect_target = sanitized_request_target(uri);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(axum::http::header::LOCATION, redirect_target)
|
||||
.body(Body::empty())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn sanitized_request_target(uri: &Uri) -> String {
|
||||
let path = if uri.path().is_empty() { "/" } else { uri.path() };
|
||||
match strip_proxy_token_query(uri.query()) {
|
||||
Some(query) if !query.is_empty() => format!("{path}?{query}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
|
||||
let query = query?;
|
||||
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
url::form_urlencoded::Serializer::new(String::new())
|
||||
.extend_pairs(filtered)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_request_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut forwarded = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
|
||||
continue;
|
||||
}
|
||||
forwarded.append(name.clone(), value.clone());
|
||||
}
|
||||
|
||||
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let host_value = match target_base_url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
forwarded.insert(
|
||||
axum::http::header::HOST,
|
||||
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
|
||||
let target_origin = target_base_url.origin().ascii_serialization();
|
||||
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
|
||||
forwarded.insert(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
|
||||
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
|
||||
forwarded.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
|
||||
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
|
||||
let parsed = Url::parse(referer).ok()?;
|
||||
|
||||
let mut rewritten = target_base_url.clone();
|
||||
rewritten.set_path(parsed.path());
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
Some(rewritten.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_response_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
local_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut rewritten = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if *name == axum::http::header::LOCATION {
|
||||
if let Ok(location) = value.to_str() {
|
||||
let next = rewrite_location(location, target_base_url, local_base_url);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if *name == axum::http::header::SET_COOKIE {
|
||||
if let Ok(cookie) = value.to_str() {
|
||||
let next = rewrite_set_cookie(cookie);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewritten.append(name.clone(), value.clone());
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn rewrite_set_cookie(cookie: &str) -> String {
|
||||
cookie
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
|
||||
let Ok(parsed) = target_base_url.join(location) else {
|
||||
return location.to_string();
|
||||
};
|
||||
|
||||
if parsed.origin() != target_base_url.origin() {
|
||||
return location.to_string();
|
||||
}
|
||||
|
||||
let mut rewritten = local_base_url.clone();
|
||||
rewritten.set_path(parsed.path());
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
rewritten.to_string()
|
||||
}
|
||||
|
||||
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
|
||||
if error.is_timeout() {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
|
||||
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
|
||||
HOP_BY_HOP
|
||||
.get_or_init(|| {
|
||||
HashSet::from([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
])
|
||||
})
|
||||
.contains(name.as_str())
|
||||
}
|
||||
Reference in New Issue
Block a user