feat: add cluster discovery, failover, VM management, and console support

This commit is contained in:
Matt
2026-08-09 23:01:51 +00:00
parent 7bb3ba48f0
commit 190fdb2573
100 changed files with 11351 additions and 1604 deletions
+1846 -190
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,9 @@ pub enum Error {
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Cannot connect to server: {0}")]
ConnectionFailed(String),
#[error("Certificate error: {0}")]
CertificateError(String),
+235 -54
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
@@ -6,12 +7,19 @@ use tauri::Manager;
use tokio::sync::RwLock;
mod connection;
mod proxmox;
mod error;
mod proxmox;
pub mod tls;
mod websocket;
use connection::ConnectionManager;
use error::Error;
pub use connection::{
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
};
pub use error::Error;
pub use proxmox::{
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
RestoreConfig,
};
use websocket::WebSocketManager;
pub type Result<T> = std::result::Result<T, Error>;
@@ -23,13 +31,36 @@ pub struct ConnectionConfig {
pub name: String,
pub primary: EndpointConfig,
pub fallbacks: Vec<EndpointConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>,
pub trusted: bool,
#[serde(default)]
pub accept_untrusted: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cluster_name: Option<String>,
pub is_cluster: bool,
pub auth_mode: String,
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<DiscoveredNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_id: Option<String>,
}
/// A node discovered in the cluster connected through a
/// [`ConnectionConfig`]. `url` is the endpoint URL derived from the
/// connection's primary endpoint (same scheme and port, host replaced with
/// the node's cluster IP or name); `is_primary` marks the node that the
/// connection is anchored on.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredNode {
pub name: String,
pub url: String,
pub status: String,
pub is_primary: bool,
pub local: bool,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -37,6 +68,7 @@ pub struct ConnectionConfig {
pub struct EndpointConfig {
pub url: String,
pub node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
@@ -48,7 +80,38 @@ pub struct LoginResult {
pub csrf_token: String,
}
#[derive(Clone, Serialize, Deserialize)]
/// The outcome of a `connect` attempt. A standalone success reports
/// `merged_into: None`; connecting to a cluster already represented by another
/// connection folds the new endpoint into it and reports
/// `merged_into: Some(target)`. When no endpoint is reachable the connection
/// is still tracked and reported with `status: "failed"` instead of an error.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectResult {
/// The effective connection id to use after the connect (the merge target
/// when this connect merged into an existing same-cluster connection).
pub connection_id: String,
/// `Some(target)` when this connect merged into an existing connection.
pub merged_into: Option<String>,
/// `"connected"` | `"failover"` | `"failed"`.
pub status: String,
}
/// A snapshot of a connection's runtime state for the status bar: the
/// effective status, the primary and currently-serving endpoints, and the last
/// discovered node list.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionStatusInfo {
pub connection_id: String,
/// `"connected"` | `"failover"` | `"failed"` | `"disconnected"`.
pub status: String,
pub primary_url: String,
pub current_endpoint_url: String,
pub nodes: Vec<DiscoveredNode>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CertificateInfo {
pub fingerprint: String,
@@ -64,38 +127,91 @@ struct AppState {
ws_manager: Arc<RwLock<WebSocketManager>>,
}
/// Returns the path of the persisted connections file.
///
/// Lives at `{app_config_dir}/proxmoxdesktop/connections.json`; the parent
/// directory is created lazily when the file is first written.
fn connections_file(app: &tauri::AppHandle) -> Result<PathBuf> {
let dir = app.path().app_config_dir()?;
Ok(dir.join("proxmoxdesktop").join("connections.json"))
}
#[tauri::command]
async fn load_connections(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<LoadResult> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.load_connections(&path).await
}
#[tauri::command]
async fn add_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.add_connection(config).await
let path = connections_file(&app)?;
manager.add_connection(config, &path).await
}
#[tauri::command]
async fn remove_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.remove_connection(&id).await
let path = connections_file(&app)?;
manager.remove_connection(&id, &path).await
}
#[tauri::command]
async fn update_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.update_connection(config, &path).await
}
#[tauri::command]
async fn connect_to_server(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<()> {
app: tauri::AppHandle,
) -> Result<ConnectResult> {
let mut manager = state.connection_manager.write().await;
manager.connect(&id).await
let path = connections_file(&app)?;
manager.connect(&id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(
async fn get_connection_status(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<ConnectionStatusInfo> {
let mut manager = state.connection_manager.write().await;
manager.status_info(&connection_id).await
}
#[tauri::command]
async fn set_active_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.set_active_connection(id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(state: tauri::State<'_, AppState>, id: String) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.disconnect(&id).await
}
@@ -114,9 +230,11 @@ async fn trust_certificate(
state: tauri::State<'_, AppState>,
id: String,
fingerprint: String,
app: tauri::AppHandle,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.trust_certificate(&id, &fingerprint).await
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.trust_certificate(&id, &fingerprint, &path).await
}
#[tauri::command]
@@ -164,7 +282,9 @@ async fn get_storage_detail(
storage: String,
) -> Result<proxmox::StorageDetail> {
let manager = state.connection_manager.read().await;
manager.get_storage_detail(&connection_id, &node, &storage).await
manager
.get_storage_detail(&connection_id, &node, &storage)
.await
}
#[tauri::command]
@@ -191,9 +311,12 @@ async fn start_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.start_vm(&connection_id, &node, vmid).await
manager
.start_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -202,9 +325,10 @@ async fn stop_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.stop_vm(&connection_id, &node, vmid).await
manager.stop_vm(&connection_id, &node, vmid, &vm_type).await
}
#[tauri::command]
@@ -213,9 +337,12 @@ async fn shutdown_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.shutdown_vm(&connection_id, &node, vmid).await
manager
.shutdown_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -224,9 +351,12 @@ async fn reboot_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.reboot_vm(&connection_id, &node, vmid).await
manager
.reboot_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -235,9 +365,12 @@ async fn suspend_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.suspend_vm(&connection_id, &node, vmid).await
manager
.suspend_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -246,9 +379,12 @@ async fn resume_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resume_vm(&connection_id, &node, vmid).await
manager
.resume_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -257,9 +393,12 @@ async fn get_disks(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Disk>> {
let manager = state.connection_manager.read().await;
manager.get_disks(&connection_id, &node, vmid).await
manager
.get_disks(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -268,10 +407,13 @@ async fn add_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddDiskConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_disk(&connection_id, &node, vmid, config).await
manager
.add_disk(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -280,11 +422,14 @@ async fn resize_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
size: u64,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resize_disk(&connection_id, &node, vmid, &disk, size).await
manager
.resize_disk(&connection_id, &node, vmid, &vm_type, &disk, size)
.await
}
#[tauri::command]
@@ -293,10 +438,13 @@ async fn remove_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_disk(&connection_id, &node, vmid, &disk).await
manager
.remove_disk(&connection_id, &node, vmid, &vm_type, &disk)
.await
}
#[tauri::command]
@@ -305,11 +453,14 @@ async fn move_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
storage: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await
manager
.move_disk(&connection_id, &node, vmid, &vm_type, &disk, &storage)
.await
}
#[tauri::command]
@@ -318,9 +469,12 @@ async fn get_network_interfaces(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::NetworkInterface>> {
let manager = state.connection_manager.read().await;
manager.get_network_interfaces(&connection_id, &node, vmid).await
manager
.get_network_interfaces(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -329,10 +483,13 @@ async fn add_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_nic(&connection_id, &node, vmid, config).await
manager
.add_nic(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -341,11 +498,14 @@ async fn edit_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
config: proxmox::EditNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.edit_nic(&connection_id, &node, vmid, &nic, config).await
manager
.edit_nic(&connection_id, &node, vmid, &vm_type, &nic, config)
.await
}
#[tauri::command]
@@ -354,10 +514,13 @@ async fn remove_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_nic(&connection_id, &node, vmid, &nic).await
manager
.remove_nic(&connection_id, &node, vmid, &vm_type, &nic)
.await
}
#[tauri::command]
@@ -366,9 +529,12 @@ async fn get_snapshots(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Snapshot>> {
let manager = state.connection_manager.read().await;
manager.get_snapshots(&connection_id, &node, vmid).await
manager
.get_snapshots(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -377,10 +543,13 @@ async fn create_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::CreateSnapshotConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.create_snapshot(&connection_id, &node, vmid, config).await
manager
.create_snapshot(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -389,10 +558,13 @@ async fn delete_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.delete_snapshot(&connection_id, &node, vmid, &name).await
manager
.delete_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -401,10 +573,13 @@ async fn rollback_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.rollback_snapshot(&connection_id, &node, vmid, &name).await
manager
.rollback_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -413,11 +588,14 @@ async fn migrate_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
target_node: String,
online: bool,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await
manager
.migrate_vm(&connection_id, &node, vmid, &vm_type, &target_node, online)
.await
}
// Authentication commands
@@ -429,7 +607,9 @@ async fn login_with_password(
password: String,
) -> Result<LoginResult> {
let manager = state.connection_manager.read().await;
manager.login_with_password(&url, &username, &password).await
manager
.login_with_password(&url, &username, &password)
.await
}
#[tauri::command]
@@ -443,10 +623,7 @@ async fn login_with_token(
}
#[tauri::command]
async fn logout(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<()> {
async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.logout(&connection_id).await
}
@@ -461,7 +638,7 @@ async fn get_stored_credentials(
}
// Console proxy types
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VNCProxyResponse {
pub ticket: String,
@@ -469,7 +646,7 @@ pub struct VNCProxyResponse {
pub cert: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TermProxyResponse {
pub ticket: String,
@@ -554,7 +731,9 @@ async fn get_backups(
storage: Option<String>,
) -> Result<Vec<proxmox::Backup>> {
let manager = state.connection_manager.read().await;
manager.get_backups(&connection_id, storage.as_deref()).await
manager
.get_backups(&connection_id, storage.as_deref())
.await
}
#[tauri::command]
@@ -689,10 +868,14 @@ pub fn run() {
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
})
.invoke_handler(tauri::generate_handler![
load_connections,
add_connection,
remove_connection,
update_connection,
set_active_connection,
connect_to_server,
disconnect_from_server,
get_connection_status,
login_with_password,
login_with_token,
logout,
@@ -759,23 +942,21 @@ pub fn run() {
.tooltip("ProxmoxDesktop")
.icon(app.default_window_icon().cloned().expect("no default icon"))
.menu(&menu)
.on_menu_event(move |app, event| {
match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
.on_menu_event(move |app, event| match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;
+54
View File
@@ -7,6 +7,7 @@ pub struct Disk {
pub size: u64,
pub storage: String,
pub format: String,
#[serde(default)]
pub usage: Option<String>,
}
@@ -21,7 +22,9 @@ pub struct AddDiskConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Node {
#[serde(default)]
pub node: String,
#[serde(default)]
pub status: String,
pub cpu: f64,
pub maxcpu: u32,
@@ -38,25 +41,44 @@ pub struct Node {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VM {
#[serde(default)]
pub vmid: u32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub status: String,
pub r#type: String,
#[serde(default)]
pub node: String,
#[serde(default)]
pub cpu: f64,
#[serde(default)]
pub cpus: u32,
#[serde(default)]
pub mem: u64,
#[serde(default)]
pub maxmem: u64,
#[serde(default)]
pub disk: u64,
#[serde(default)]
pub maxdisk: u64,
#[serde(default)]
pub uptime: u64,
#[serde(default)]
pub netin: u64,
#[serde(default)]
pub netout: u64,
#[serde(default)]
pub diskread: u64,
#[serde(default)]
pub diskwrite: u64,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub template: Option<u32>,
#[serde(default)]
pub lock: Option<String>,
#[serde(default)]
pub tags: Option<String>,
}
@@ -66,12 +88,19 @@ pub struct Storage {
pub storage: String,
pub r#type: String,
pub content: String,
#[serde(default)]
pub active: u32,
#[serde(default)]
pub enabled: u32,
#[serde(default)]
pub shared: u32,
#[serde(default)]
pub used: u64,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub avail: u64,
#[serde(default)]
pub node: String,
}
@@ -79,15 +108,19 @@ pub struct Storage {
#[serde(rename_all = "camelCase")]
pub struct Task {
pub upid: String,
#[serde(default)]
pub node: String,
pub pid: u32,
pub pstart: u64,
pub starttime: u64,
#[serde(default)]
pub endtime: Option<u64>,
pub r#type: String,
pub id: String,
pub user: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub exitstatus: Option<String>,
}
@@ -114,9 +147,11 @@ pub struct ClusterNode {
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub name: String,
#[serde(default)]
pub description: String,
pub snaptime: u64,
pub vmstate: u32,
#[serde(default)]
pub parent: Option<String>,
}
@@ -140,9 +175,13 @@ pub struct NetworkInterface {
pub name: String,
pub model: String,
pub macaddr: String,
#[serde(default)]
pub bridge: Option<String>,
#[serde(default)]
pub tag: Option<u32>,
#[serde(default)]
pub firewall: Option<u32>,
#[serde(default)]
pub link_down: Option<u32>,
}
@@ -189,10 +228,15 @@ pub struct BackupJob {
pub schedule: String,
pub all: u32,
pub enabled: u32,
#[serde(default)]
pub node: Option<String>,
#[serde(default)]
pub vmid: Option<String>,
#[serde(default)]
pub compress: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub quiet: Option<u32>,
}
@@ -224,8 +268,11 @@ pub struct RestoreConfig {
pub struct StorageContent {
pub content: String,
pub ctime: u64,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub subtype: Option<String>,
pub volid: String,
}
@@ -236,11 +283,18 @@ pub struct StorageDetail {
pub storage: String,
pub r#type: String,
pub content: String,
#[serde(default)]
pub active: u32,
#[serde(default)]
pub enabled: u32,
#[serde(default)]
pub shared: u32,
#[serde(default)]
pub used: u64,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub avail: u64,
#[serde(default)]
pub node: String,
}
+218
View File
@@ -0,0 +1,218 @@
//! Application-layer TLS certificate capture and TOFU (Trust On First Use)
//! fingerprint pinning.
//!
//! The reqwest transport used by the rest of the app is configured to accept
//! self-signed and otherwise invalid certificates, which is typical for
//! home-lab Proxmox servers. To keep connections safe without a full CA trust
//! chain, this module performs its own certificate capture at the connect
//! layer: it opens a raw TLS connection, extracts the presented leaf
//! certificate, and computes its SHA-256 fingerprint. [`verify_server_certificate`]
//! then compares that fingerprint against the value pinned when the server was
//! first trusted.
use crate::{CertificateInfo, Error};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use url::Url;
use x509_cert::der::Decode;
/// Verifier that accepts every presented certificate chain.
///
/// The actual security decision is deferred to the application layer: the leaf
/// certificate's SHA-256 fingerprint is captured on each connection and
/// compared against the pinned value recorded on first use (TOFU).
#[derive(Debug)]
struct AcceptAllVerifier;
impl ServerCertVerifier for AcceptAllVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Installs the ring crypto provider once per process.
///
/// `install_default` is idempotent and thread-safe; subsequent calls return
/// `Err` with the already-installed provider, which is ignored here.
fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
/// Opens a raw TLS connection to `url` and returns the DER-encoded leaf
/// certificate presented by the server.
async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
ensure_crypto_provider();
let parsed =
Url::parse(url).map_err(|e| Error::InvalidUrl(format!("Invalid URL '{}': {}", url, e)))?;
if parsed.scheme() != "https" {
return Err(Error::InvalidUrl(format!(
"Only https URLs are supported, got '{}://'",
parsed.scheme()
)));
}
let host = parsed
.host_str()
.ok_or_else(|| Error::InvalidUrl(format!("URL '{}' has no host", url)))?
.to_string();
let port = parsed.port().unwrap_or(443);
let server_name = ServerName::try_from(host.clone()).map_err(|e| {
Error::InvalidUrl(format!(
"Invalid hostname '{}' in URL '{}': {}",
host, url, e
))
})?;
let addr = format!("{}:{}", host, port);
let addresses = tokio::net::lookup_host(&addr)
.await
.map_err(|e| Error::InvalidUrl(format!("Cannot resolve '{}': {}", addr, e)))?
.collect::<Vec<_>>();
let address = addresses
.first()
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
let tcp = TcpStream::connect(address)
.await
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAllVerifier))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let tls = connector.connect(server_name, tcp).await.map_err(|e| {
Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e))
})?;
let (_, session) = tls.get_ref();
let leaf = session
.peer_certificates()
.and_then(|certs| certs.first())
.ok_or_else(|| {
Error::CertificateError(format!("Server '{}' presented no TLS certificate", addr))
})?;
Ok(leaf.as_ref().to_vec())
}
/// Computes the SHA-256 fingerprint of a DER-encoded certificate in the
/// `AA:BB:CC:...` uppercase colon-separated format used across the app.
fn fingerprint_of(der: &[u8]) -> String {
let digest = Sha256::digest(der);
let hex = hex::encode_upper(digest);
hex.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
.collect::<Vec<_>>()
.join(":")
}
/// Captures just the SHA-256 fingerprint of the certificate presented by `url`.
pub async fn capture_fingerprint(url: &str) -> crate::Result<String> {
let der = capture_leaf_certificate_der(url).await?;
Ok(fingerprint_of(&der))
}
/// Compares a captured fingerprint against a pinned value.
///
/// The comparison is case-insensitive and ignores the `:` (and other
/// non-hex) separators, so `ab:cd` and `AB:CD` (or even `abcd`) all match.
pub fn verify_pin(fingerprint: &str, pinned: &str) -> bool {
fn normalize(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(char::to_uppercase)
.collect()
}
normalize(fingerprint) == normalize(pinned)
}
/// Connects to `url`, captures the presented leaf certificate, and returns a
/// [`CertificateInfo`] with its fingerprint and parsed metadata.
pub async fn fetch_certificate_info(url: &str) -> crate::Result<CertificateInfo> {
let der = capture_leaf_certificate_der(url).await?;
let cert = x509_cert::Certificate::from_der(&der)
.map_err(|e| Error::CertificateError(format!("Failed to parse certificate: {}", e)))?;
let tbs = &cert.tbs_certificate;
let issuer = tbs.issuer.to_string();
let subject = tbs.subject.to_string();
let self_signed = issuer == subject;
Ok(CertificateInfo {
fingerprint: fingerprint_of(&der),
issuer,
subject,
valid_from: tbs.validity.not_before.to_string(),
valid_to: tbs.validity.not_after.to_string(),
self_signed,
})
}
/// Captures the certificate fingerprint of `url` and verifies it against the
/// pinned fingerprint recorded on first use.
///
/// * `pinned == None`: first use — returns the fingerprint so the caller can
/// record it (the TOFU trust step).
/// * `pinned == Some(...)` and it matches the captured fingerprint: `Ok`.
/// * `pinned == Some(...)` and it does not match: returns
/// [`Error::CertificateError`] unless `accept_untrusted` is set, in which
/// case the mismatch is ignored (escape hatch).
///
/// The returned value is the captured fingerprint.
pub async fn verify_server_certificate(
url: &str,
pinned: Option<&str>,
accept_untrusted: bool,
) -> crate::Result<String> {
let fingerprint = capture_fingerprint(url).await?;
if let Some(pinned) = pinned {
if !verify_pin(&fingerprint, pinned) && !accept_untrusted {
return Err(Error::CertificateError(format!(
"Certificate fingerprint changed. Expected '{}' but got '{}'. \
This could be a man-in-the-middle attack.",
pinned, fingerprint
)));
}
}
Ok(fingerprint)
}
+2 -5
View File
@@ -4,8 +4,8 @@ use std::collections::HashMap;
use tauri::Emitter;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use crate::error::Error;
@@ -236,10 +236,7 @@ fn handle_ws_message(connection_id: &str, text: &str, app_handle: &tauri::AppHan
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
vmid: data
.get("vmid")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
vmid: data.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
status: data
.get("status")
.and_then(|v| v.as_str())