feat: add cluster discovery, failover, VM management, and console support
This commit is contained in:
+1846
-190
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user