mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-11 13:50:14 +00:00
00a8f42572
Split SPICE into two connection types: "spice" (direct libvirt/QEMU) and "proxmox" (PVE console brokered via the spiceproxy API). Both produce a guacd SPICE connection. - Deliver the SPICE ticket/password as a connect arg instead of a post-connect argv stream, so it is set before guacd authenticates. Fixes an auth race that produced intermittent "SPICE authentication failed". - TLS-only SPICE sends an empty plain port so guacd connects via tls-port rather than plaintext against a TLS endpoint. - Proxmox node is optional: resolve it from the VM id via /cluster/resources (as the PVE web UI does). - Split the PVE API token into a visible Token ID (shown in the User column) and a masked secret; join them as "id=secret" for the API. - Surface the PVE response body on non-2xx (safe: only a 2xx spiceproxy response carries a ticket), turning opaque 500s into actionable messages. - SSH tunneling for Proxmox: tunnel both the PVE API call and the spiceproxy connection through the jump-host chain in-branch. Also rewrite tls_port (not port) for direct-SPICE TLS over a tunnel. - Store proxmox fields on address book entries; populate Host/User columns; orange Proxmox badge. Runtime dep: libspice-client-glib-2.0-8.
2157 lines
84 KiB
Rust
2157 lines
84 KiB
Rust
//! HashiVault / OpenBao KV v2 client with AppRole authentication.
|
||
//!
|
||
//! Stores address book entries (connection credentials) in Vault.
|
||
//! Path structure:
|
||
//! <mount>/data/<base_path>/shared/<folder>/<entry> — shared across instances
|
||
//! <mount>/data/<base_path>/instance/<name>/<folder>/<entry> — instance-specific
|
||
//!
|
||
//! Each folder has a `.config` sentinel key containing `FolderConfig`
|
||
//! (allowed_groups, description) that controls OIDC group-based access.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
use tokio::sync::RwLock;
|
||
|
||
use crate::config::VaultConfig;
|
||
use crate::tunnel;
|
||
|
||
// ── Error type ──
|
||
|
||
#[derive(Debug)]
|
||
pub enum VaultError {
|
||
Auth(String),
|
||
NotFound,
|
||
Forbidden,
|
||
Http(reqwest::Error),
|
||
Parse(String),
|
||
BadName(String),
|
||
}
|
||
|
||
impl std::fmt::Display for VaultError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::Auth(msg) => write!(f, "vault auth error: {}", msg),
|
||
Self::NotFound => write!(f, "not found in vault"),
|
||
Self::Forbidden => write!(f, "vault access denied"),
|
||
Self::Http(e) => write!(f, "vault HTTP error: {}", e),
|
||
Self::Parse(msg) => write!(f, "vault response parse error: {}", msg),
|
||
Self::BadName(msg) => write!(f, "invalid name: {}", msg),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<reqwest::Error> for VaultError {
|
||
fn from(e: reqwest::Error) -> Self {
|
||
VaultError::Http(e)
|
||
}
|
||
}
|
||
|
||
// ── Data types ──
|
||
|
||
/// Folder access configuration stored at `<folder>/.config` in Vault.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct FolderConfig {
|
||
pub allowed_groups: Vec<String>,
|
||
#[serde(default)]
|
||
pub description: String,
|
||
/// When true, if the folder's own `allowed_groups` doesn't grant access,
|
||
/// the access check walks up the parent path and tries each ancestor's
|
||
/// config. New folders default to `true` in the UI; legacy configs
|
||
/// deserialise as `false` so existing deployments keep their
|
||
/// per-folder-only semantics until an admin opts in.
|
||
#[serde(default)]
|
||
pub inherit_from_parent: bool,
|
||
}
|
||
|
||
/// A connection entry stored in Vault.
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct AddressBookEntry {
|
||
#[serde(rename = "type")]
|
||
pub session_type: String, // "ssh", "rdp", "vnc", "web"
|
||
pub hostname: Option<String>,
|
||
pub port: Option<u16>,
|
||
pub username: Option<String>,
|
||
pub password: Option<String>,
|
||
pub private_key: Option<String>,
|
||
pub url: Option<String>,
|
||
pub domain: Option<String>,
|
||
pub security: Option<String>,
|
||
pub ignore_cert: Option<bool>,
|
||
pub display_name: Option<String>,
|
||
/// Override drive/file transfer setting for this entry.
|
||
pub enable_drive: Option<bool>,
|
||
/// NLA auth package: "kerberos", "ntlm", or empty (negotiate).
|
||
pub auth_pkg: Option<String>,
|
||
/// Kerberos KDC URL (optional).
|
||
pub kdc_url: Option<String>,
|
||
/// Whether to prompt for credentials at connect time (even if stored creds exist).
|
||
pub prompt_credentials: Option<bool>,
|
||
/// VNC color depth (8, 16, 24, 32). Default: 24.
|
||
pub color_depth: Option<u8>,
|
||
/// Multi-hop SSH tunnel jump hosts (ordered).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_hosts: Option<Vec<tunnel::JumpHost>>,
|
||
/// Legacy: single SSH tunnel jump host (migrated to jump_hosts on read).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_host: Option<String>,
|
||
/// Legacy: SSH tunnel jump port (default: 22).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_port: Option<u16>,
|
||
/// Legacy: SSH tunnel jump username.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_username: Option<String>,
|
||
/// Legacy: SSH tunnel jump password.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_password: Option<String>,
|
||
/// Legacy: SSH tunnel jump private key (PEM).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub jump_private_key: Option<String>,
|
||
/// RDP RemoteApp program path (RAIL).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub remote_app: Option<String>,
|
||
/// RDP RemoteApp working directory.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub remote_app_dir: Option<String>,
|
||
/// RDP RemoteApp command-line arguments.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub remote_app_args: Option<String>,
|
||
/// Override recording enabled/disabled for this entry.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_recording: Option<bool>,
|
||
/// Maximum number of recordings to keep for this entry (0 = unlimited).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub max_recordings: Option<u32>,
|
||
/// Enable SSH typescript recording for this entry (#159). Default off
|
||
/// (per-connection opt-in). Only effective for SSH sessions and only
|
||
/// when `[recording].typescript_path` is configured globally.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub record_typescript: Option<bool>,
|
||
/// Login script filename (relative to login_scripts_dir) to run after browser spawns.
|
||
/// Only applicable to web sessions. The script receives CDP port and credentials
|
||
/// via environment variables and stdin JSON.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub login_script: Option<String>,
|
||
/// Autofill credentials for web sessions. JSON array of objects:
|
||
/// [{"url": "https://example.com", "username": "$USERNAME", "password": "$PASSWORD"}]
|
||
/// $USERNAME and $PASSWORD are substituted from the entry's credentials.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub autofill: Option<String>,
|
||
/// Allowed domains for web sessions. When set, Chromium can only reach
|
||
/// these domains (plus localhost). Uses --host-rules to block all others.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub allowed_domains: Option<Vec<String>>,
|
||
/// Disable clipboard copy (server → client). Prevents copying from the remote session.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub disable_copy: Option<bool>,
|
||
/// Disable clipboard paste (client → server). Prevents pasting into the remote session.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub disable_paste: Option<bool>,
|
||
/// Optional banner text shown before the session starts. User must click Continue to proceed.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub banner: Option<String>,
|
||
/// Enable RDP Graphics Pipeline Extension (GFX). Enables RemoteFX codec for better video.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_gfx: Option<bool>,
|
||
/// Enable desktop composition (DWM). Improves video overlay rendering in RDP.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_desktop_composition: Option<bool>,
|
||
/// Show the remote desktop wallpaper. Disabled by default to save bandwidth.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_wallpaper: Option<bool>,
|
||
/// Enable window/control theming (visual styles). Disabled by default to save bandwidth.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_theming: Option<bool>,
|
||
/// Show window contents while dragging. Disabled by default to save bandwidth.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_full_window_drag: Option<bool>,
|
||
/// Force lossless encoding (PNG only). Better for text-heavy, low-bandwidth sessions.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub force_lossless: Option<bool>,
|
||
/// Enable H.264 passthrough. Passes raw H.264 from xrdp to browser WebCodecs decoder.
|
||
/// Requires GFX enabled and xrdp with x264 on the target. Default: true when GFX enabled.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub enable_h264: Option<bool>,
|
||
/// Docker image for VDI sessions (e.g. "myregistry/desktop:latest").
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_image: Option<String>,
|
||
/// CPU limit override for VDI container (fractional cores). Uses config default if unset.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_cpu_limit: Option<f64>,
|
||
/// Memory limit override for VDI container in MB. Uses config default if unset.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_memory_limit: Option<u64>,
|
||
/// Extra environment variables for VDI container (key=value).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_env: Option<std::collections::HashMap<String, String>>,
|
||
/// Override idle timeout for VDI container in minutes. Uses global default if unset.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_idle_timeout_mins: Option<u64>,
|
||
/// Optional fixed username for the VDI container's RDP login. When set,
|
||
/// rustguac uses this username for the RDP connect into the container
|
||
/// instead of deriving one from the operator's identity. Useful when the
|
||
/// container image has a baked-in user that doesn't honour the
|
||
/// VDI_USERNAME env var.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_username: Option<String>,
|
||
/// Optional fixed password matching `container_username`. When set,
|
||
/// rustguac uses this password for the RDP connect instead of
|
||
/// generating an ephemeral one. Stored in Vault alongside the entry.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub container_password: Option<String>,
|
||
/// Allow users to generate a Share URL for sessions from this entry.
|
||
/// Default: false (admin must opt in per entry). Gates the Share
|
||
/// button in the Connections Active Sessions card — when `false`,
|
||
/// `SessionInfo.share_url` is serialised as `None`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub allow_sharing: Option<bool>,
|
||
/// GitHub #103: when true and this is the calling user's ONLY visible
|
||
/// entry, the Connections page auto-connects to it on first load.
|
||
/// Admin opt-in per entry. No effect for users with more than one
|
||
/// visible entry.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_open_if_singleton: Option<bool>,
|
||
/// GitHub #154: when true, the client enters fullscreen on first user
|
||
/// gesture after connect and locks the Escape key (Chromium) so it
|
||
/// reaches the remote session instead of exiting fullscreen.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub fullscreen_on_connect: Option<bool>,
|
||
/// When true, the clipboard/files side tabs auto-hide when idle and
|
||
/// reappear when the pointer nears the left edge of the display.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub autohide_side_tabs: Option<bool>,
|
||
/// SPICE: connect using TLS.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub spice_tls: Option<bool>,
|
||
/// SPICE: TLS port (if the encrypted port differs from `port`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub spice_tls_port: Option<u16>,
|
||
/// SPICE: PEM CA certificate for verifying the server TLS (e.g. a Proxmox cluster CA).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub spice_ca_cert: Option<String>,
|
||
/// SPICE: expected TLS certificate subject (Proxmox "host-subject").
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub spice_cert_subject: Option<String>,
|
||
/// SPICE: proxy URL, e.g. a Proxmox SPICE proxy "http://host:3128".
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub spice_proxy: Option<String>,
|
||
/// Proxmox VE console: PVE API base URL, full URL incl. scheme + port
|
||
/// (e.g. "https://pve.example.com:8006").
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_url: Option<String>,
|
||
/// Proxmox node name hosting the VM.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_node: Option<String>,
|
||
/// Proxmox VM id (QEMU) whose console to open.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_vmid: Option<u32>,
|
||
/// Proxmox API token id ("user@realm!tokenname") — non-secret, shown in the
|
||
/// UI (User column).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_token_id: Option<String>,
|
||
/// Proxmox API token secret (UUID). Credential — never returned to the
|
||
/// browser (see EntryInfo::has_proxmox_token_secret).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_token_secret: Option<String>,
|
||
/// Verify the PVE API + SPICE-proxy TLS certificate (default false; PVE
|
||
/// ships a self-signed cluster cert).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_verify_tls: Option<bool>,
|
||
}
|
||
|
||
impl AddressBookEntry {
|
||
/// Migrate legacy flat jump_host fields into the jump_hosts array.
|
||
/// If `jump_hosts` is already set, this is a no-op.
|
||
pub fn normalize_jump_hosts(&mut self) {
|
||
if self.jump_hosts.is_some() {
|
||
return;
|
||
}
|
||
if let Some(ref host) = self.jump_host {
|
||
if !host.is_empty() {
|
||
self.jump_hosts = Some(vec![tunnel::JumpHost {
|
||
hostname: host.clone(),
|
||
port: self.jump_port.unwrap_or(22),
|
||
username: self.jump_username.clone().unwrap_or_default(),
|
||
password: self.jump_password.clone(),
|
||
private_key: self.jump_private_key.clone(),
|
||
host_key: None,
|
||
}]);
|
||
}
|
||
}
|
||
// Clear legacy fields so they don't get written back
|
||
self.jump_host = None;
|
||
self.jump_port = None;
|
||
self.jump_username = None;
|
||
self.jump_password = None;
|
||
self.jump_private_key = None;
|
||
}
|
||
}
|
||
|
||
/// Entry metadata returned to non-admin users (credentials stripped).
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct EntryInfo {
|
||
pub name: String,
|
||
pub session_type: String,
|
||
pub hostname: Option<String>,
|
||
pub port: Option<u16>,
|
||
pub username: Option<String>,
|
||
pub url: Option<String>,
|
||
pub display_name: Option<String>,
|
||
pub domain: Option<String>,
|
||
pub security: Option<String>,
|
||
pub ignore_cert: Option<bool>,
|
||
pub enable_drive: Option<bool>,
|
||
/// NLA auth package: "kerberos", "ntlm", or empty (negotiate).
|
||
pub auth_pkg: Option<String>,
|
||
/// Kerberos KDC URL (optional).
|
||
pub kdc_url: Option<String>,
|
||
/// Whether to prompt for credentials at connect time.
|
||
pub prompt_credentials: Option<bool>,
|
||
/// Whether the entry has a stored password or private key.
|
||
pub has_credentials: bool,
|
||
/// VNC color depth.
|
||
pub color_depth: Option<u8>,
|
||
/// SSH tunnel jump hosts (no credentials exposed).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub jump_hosts: Option<Vec<tunnel::JumpHostInfo>>,
|
||
/// RDP RemoteApp program path.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub remote_app: Option<String>,
|
||
/// RDP RemoteApp working directory.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub remote_app_dir: Option<String>,
|
||
/// RDP RemoteApp command-line arguments.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub remote_app_args: Option<String>,
|
||
/// Override recording enabled/disabled.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_recording: Option<bool>,
|
||
/// Maximum recordings to keep for this entry.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub max_recordings: Option<u32>,
|
||
/// Enable SSH typescript recording for this entry (#159).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub record_typescript: Option<bool>,
|
||
/// Login script filename (web sessions only).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub login_script: Option<String>,
|
||
/// Autofill credentials JSON (web sessions only). Contains $PASSWORD placeholders, not actual values.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub autofill: Option<String>,
|
||
/// Allowed domains for web sessions.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub allowed_domains: Option<Vec<String>>,
|
||
/// Disable clipboard copy (server → client).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub disable_copy: Option<bool>,
|
||
/// Disable clipboard paste (client → server).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub disable_paste: Option<bool>,
|
||
/// Banner text shown before session starts.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub banner: Option<String>,
|
||
/// Credential variable names referenced by this entry (e.g. ["corp_user", "corp_password"]).
|
||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||
pub credential_variables: Vec<String>,
|
||
/// Enable RDP Graphics Pipeline Extension (GFX).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_gfx: Option<bool>,
|
||
/// Enable desktop composition (DWM).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_desktop_composition: Option<bool>,
|
||
/// Show the remote desktop wallpaper.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_wallpaper: Option<bool>,
|
||
/// Enable window/control theming (visual styles).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_theming: Option<bool>,
|
||
/// Show window contents while dragging.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_full_window_drag: Option<bool>,
|
||
/// Force lossless encoding (PNG only).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub force_lossless: Option<bool>,
|
||
/// Enable H.264 passthrough.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enable_h264: Option<bool>,
|
||
/// Docker image for VDI sessions.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_image: Option<String>,
|
||
/// CPU limit for VDI container (cores, e.g. 2.0).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_cpu_limit: Option<f64>,
|
||
/// Memory limit for VDI container (bytes).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_memory_limit: Option<u64>,
|
||
/// Environment variables to inject into the VDI container.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_env: Option<std::collections::HashMap<String, String>>,
|
||
/// Idle timeout for VDI container in minutes.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_idle_timeout_mins: Option<u64>,
|
||
/// Optional fixed VDI container username (for images with baked-in
|
||
/// accounts that don't honour VDI_USERNAME).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub container_username: Option<String>,
|
||
/// Whether a fixed VDI container password is stored on this entry.
|
||
/// The actual value is never serialised back to clients.
|
||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||
pub has_container_password: bool,
|
||
/// Whether users can generate Share URLs for sessions of this entry.
|
||
/// Defaults to false — admin must opt in per entry.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub allow_sharing: Option<bool>,
|
||
/// Auto-open on login when this is the user's only visible entry (#103).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub auto_open_if_singleton: Option<bool>,
|
||
/// Open the client in fullscreen on connect (#154).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub fullscreen_on_connect: Option<bool>,
|
||
/// Auto-hide the clipboard/files side tabs when idle.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub autohide_side_tabs: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub spice_tls: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub spice_tls_port: Option<u16>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub spice_ca_cert: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub spice_cert_subject: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub spice_proxy: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_url: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_node: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_vmid: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_verify_tls: Option<bool>,
|
||
/// Proxmox token id (non-secret; shown in the User column).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub proxmox_token_id: Option<String>,
|
||
/// Whether a Proxmox token secret is stored (the secret itself is never
|
||
/// returned; the UI shows "leave blank to keep").
|
||
pub has_proxmox_token_secret: bool,
|
||
}
|
||
|
||
impl From<(&str, &AddressBookEntry)> for EntryInfo {
|
||
fn from((name, e): (&str, &AddressBookEntry)) -> Self {
|
||
let jump_hosts = e.jump_hosts.as_ref().map(|hops| {
|
||
hops.iter()
|
||
.map(|h| tunnel::JumpHostInfo {
|
||
hostname: h.hostname.clone(),
|
||
port: h.port,
|
||
username: h.username.clone(),
|
||
host_key_fingerprint: h
|
||
.host_key
|
||
.as_ref()
|
||
.and_then(|k| tunnel::fingerprint_openssh_key(k).ok()),
|
||
})
|
||
.collect()
|
||
});
|
||
Self {
|
||
name: name.to_string(),
|
||
session_type: e.session_type.clone(),
|
||
hostname: e.hostname.clone(),
|
||
port: e.port,
|
||
username: e.username.clone(),
|
||
url: e.url.clone(),
|
||
display_name: e.display_name.clone(),
|
||
domain: e.domain.clone(),
|
||
security: e.security.clone(),
|
||
ignore_cert: e.ignore_cert,
|
||
enable_drive: e.enable_drive,
|
||
auth_pkg: e.auth_pkg.clone(),
|
||
kdc_url: e.kdc_url.clone(),
|
||
prompt_credentials: e.prompt_credentials,
|
||
has_credentials: e.password.as_ref().is_some_and(|p| !p.is_empty())
|
||
|| e.private_key.as_ref().is_some_and(|k| !k.is_empty()),
|
||
color_depth: e.color_depth,
|
||
jump_hosts,
|
||
remote_app: e.remote_app.clone(),
|
||
remote_app_dir: e.remote_app_dir.clone(),
|
||
remote_app_args: e.remote_app_args.clone(),
|
||
enable_recording: e.enable_recording,
|
||
max_recordings: e.max_recordings,
|
||
record_typescript: e.record_typescript,
|
||
login_script: e.login_script.clone(),
|
||
autofill: e.autofill.clone(),
|
||
allowed_domains: e.allowed_domains.clone(),
|
||
disable_copy: e.disable_copy,
|
||
disable_paste: e.disable_paste,
|
||
banner: e.banner.clone(),
|
||
credential_variables: entry_credential_variables(e),
|
||
enable_gfx: e.enable_gfx,
|
||
enable_desktop_composition: e.enable_desktop_composition,
|
||
enable_wallpaper: e.enable_wallpaper,
|
||
enable_theming: e.enable_theming,
|
||
enable_full_window_drag: e.enable_full_window_drag,
|
||
force_lossless: e.force_lossless,
|
||
enable_h264: e.enable_h264,
|
||
container_image: e.container_image.clone(),
|
||
container_cpu_limit: e.container_cpu_limit,
|
||
container_memory_limit: e.container_memory_limit,
|
||
container_env: e.container_env.clone(),
|
||
container_idle_timeout_mins: e.container_idle_timeout_mins,
|
||
container_username: e.container_username.clone(),
|
||
has_container_password: e.container_password.as_ref().is_some_and(|p| !p.is_empty()),
|
||
allow_sharing: e.allow_sharing,
|
||
auto_open_if_singleton: e.auto_open_if_singleton,
|
||
fullscreen_on_connect: e.fullscreen_on_connect,
|
||
autohide_side_tabs: e.autohide_side_tabs,
|
||
spice_tls: e.spice_tls,
|
||
spice_tls_port: e.spice_tls_port,
|
||
spice_ca_cert: e.spice_ca_cert.clone(),
|
||
spice_cert_subject: e.spice_cert_subject.clone(),
|
||
spice_proxy: e.spice_proxy.clone(),
|
||
proxmox_url: e.proxmox_url.clone(),
|
||
proxmox_node: e.proxmox_node.clone(),
|
||
proxmox_vmid: e.proxmox_vmid,
|
||
proxmox_verify_tls: e.proxmox_verify_tls,
|
||
proxmox_token_id: e.proxmox_token_id.clone(),
|
||
has_proxmox_token_secret: e
|
||
.proxmox_token_secret
|
||
.as_ref()
|
||
.is_some_and(|t| !t.is_empty()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Folder info returned to users.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct FolderInfo {
|
||
pub name: String,
|
||
pub description: String,
|
||
/// "shared" or "instance"
|
||
pub scope: String,
|
||
/// Full path from scope root (e.g. "Clients/Acme"). Same as name for top-level folders.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub path: Option<String>,
|
||
/// Whether this folder has subfolders (for lazy tree loading).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub has_children: Option<bool>,
|
||
}
|
||
|
||
// ── Vault client ──
|
||
|
||
pub struct VaultClient {
|
||
http: reqwest::Client,
|
||
addr: String,
|
||
mount: String,
|
||
base_path: String,
|
||
namespace: Option<String>,
|
||
instance_name: Option<String>,
|
||
token: Arc<RwLock<String>>,
|
||
role_id: String,
|
||
secret_id: String,
|
||
}
|
||
|
||
impl VaultClient {
|
||
/// Create a new Vault client and perform initial AppRole login.
|
||
pub async fn new(config: &VaultConfig, secret_id: &str) -> Result<Self, VaultError> {
|
||
if config.tls_skip_verify {
|
||
tracing::warn!(
|
||
"Vault TLS certificate verification is DISABLED (tls_skip_verify = true)"
|
||
);
|
||
}
|
||
let http = build_vault_http_client(config)?;
|
||
|
||
let client = Self {
|
||
http,
|
||
addr: config.addr.trim_end_matches('/').to_string(),
|
||
mount: config.mount.clone(),
|
||
base_path: config.base_path.clone(),
|
||
namespace: config.namespace.clone(),
|
||
instance_name: config.instance_name.clone(),
|
||
token: Arc::new(RwLock::new(String::new())),
|
||
role_id: config.role_id.clone(),
|
||
secret_id: secret_id.to_string(),
|
||
};
|
||
|
||
// Perform initial login
|
||
let (token, _ttl) = client.approle_login().await?;
|
||
*client.token.write().await = token;
|
||
|
||
Ok(client)
|
||
}
|
||
|
||
/// Authenticate via AppRole and return (token, ttl_seconds).
|
||
async fn approle_login(&self) -> Result<(String, u64), VaultError> {
|
||
let url = format!("{}/v1/auth/approle/login", self.addr);
|
||
let body = serde_json::json!({
|
||
"role_id": self.role_id,
|
||
"secret_id": self.secret_id,
|
||
});
|
||
|
||
let mut req = self.http.post(&url).json(&body);
|
||
if let Some(ref ns) = self.namespace {
|
||
req = req.header("X-Vault-Namespace", ns.as_str());
|
||
}
|
||
|
||
let resp = req.send().await?;
|
||
if !resp.status().is_success() {
|
||
let status = resp.status();
|
||
let text = resp.text().await.unwrap_or_default();
|
||
// Truncate response body — HTML error pages from reverse proxies are useless noise
|
||
let body_preview = if text.len() > 200 {
|
||
format!(
|
||
"{}... (truncated, {} bytes total)",
|
||
&text[..200],
|
||
text.len()
|
||
)
|
||
} else {
|
||
text
|
||
};
|
||
return Err(VaultError::Auth(format!(
|
||
"AppRole login failed — HTTP {} from {} — response: {}",
|
||
status.as_u16(),
|
||
url,
|
||
body_preview
|
||
)));
|
||
}
|
||
|
||
let json: serde_json::Value = resp.json().await?;
|
||
let token = json["auth"]["client_token"]
|
||
.as_str()
|
||
.ok_or_else(|| VaultError::Auth("no client_token in login response".into()))?
|
||
.to_string();
|
||
let ttl = json["auth"]["lease_duration"].as_u64().unwrap_or(3600);
|
||
|
||
Ok((token, ttl))
|
||
}
|
||
|
||
/// Spawn a background task that renews the token at 50% of TTL.
|
||
pub fn spawn_renewal_task(self: &Arc<Self>) {
|
||
let client = Arc::clone(self);
|
||
tokio::spawn(async move {
|
||
// Initial TTL — re-login to get it
|
||
let mut ttl = match client.approle_login().await {
|
||
Ok((_, ttl)) => ttl,
|
||
Err(_) => 3600,
|
||
};
|
||
|
||
loop {
|
||
let sleep_secs = std::cmp::max(ttl / 2, 30);
|
||
tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
|
||
|
||
// Try to renew the existing token first
|
||
let renewed = client.renew_token().await;
|
||
match renewed {
|
||
Ok(new_ttl) => {
|
||
tracing::debug!("Vault token renewed, TTL: {}s", new_ttl);
|
||
ttl = new_ttl;
|
||
}
|
||
Err(_) => {
|
||
// Renewal failed — try full re-login
|
||
tracing::warn!("Vault token renewal failed, attempting re-login");
|
||
match client.approle_login().await {
|
||
Ok((new_token, new_ttl)) => {
|
||
*client.token.write().await = new_token;
|
||
ttl = new_ttl;
|
||
tracing::info!("Vault re-login successful, TTL: {}s", new_ttl);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("Vault re-login failed: {}", e);
|
||
ttl = 60; // retry quickly
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Renew the current token. Returns new TTL.
|
||
async fn renew_token(&self) -> Result<u64, VaultError> {
|
||
let url = format!("{}/v1/auth/token/renew-self", self.addr);
|
||
let token = self.token.read().await.clone();
|
||
|
||
let mut req = self.http.post(&url).header("X-Vault-Token", &token);
|
||
if let Some(ref ns) = self.namespace {
|
||
req = req.header("X-Vault-Namespace", ns.as_str());
|
||
}
|
||
|
||
let resp = req.send().await?;
|
||
if !resp.status().is_success() {
|
||
return Err(VaultError::Auth("token renewal failed".into()));
|
||
}
|
||
|
||
let json: serde_json::Value = resp.json().await?;
|
||
Ok(json["auth"]["lease_duration"].as_u64().unwrap_or(3600))
|
||
}
|
||
|
||
/// Make an authenticated request to Vault. Retries once on 403 with re-login.
|
||
async fn request(
|
||
&self,
|
||
method: reqwest::Method,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
) -> Result<reqwest::Response, VaultError> {
|
||
let url = format!("{}{}", self.addr, path);
|
||
|
||
let do_request = |token: String| {
|
||
let mut req = self
|
||
.http
|
||
.request(method.clone(), &url)
|
||
.header("X-Vault-Token", &token);
|
||
if let Some(ref ns) = self.namespace {
|
||
req = req.header("X-Vault-Namespace", ns.as_str());
|
||
}
|
||
if let Some(b) = body {
|
||
req = req.json(b);
|
||
}
|
||
req.send()
|
||
};
|
||
|
||
let token = self.token.read().await.clone();
|
||
let resp = do_request(token).await?;
|
||
|
||
if resp.status() == reqwest::StatusCode::FORBIDDEN {
|
||
// Re-login and retry once
|
||
tracing::debug!("Vault 403, attempting re-login and retry");
|
||
match self.approle_login().await {
|
||
Ok((new_token, _)) => {
|
||
*self.token.write().await = new_token.clone();
|
||
let resp = do_request(new_token).await?;
|
||
Ok(resp)
|
||
}
|
||
Err(e) => Err(e),
|
||
}
|
||
} else {
|
||
Ok(resp)
|
||
}
|
||
}
|
||
|
||
// ── Path helpers ──
|
||
|
||
/// Returns the path prefixes to scan: ["shared"] and optionally ["instance/<name>"].
|
||
fn scope_prefixes(&self) -> Vec<(&str, String)> {
|
||
let mut prefixes = vec![("shared", "shared".to_string())];
|
||
if let Some(ref name) = self.instance_name {
|
||
prefixes.push(("instance", format!("instance/{}", name)));
|
||
}
|
||
prefixes
|
||
}
|
||
|
||
fn data_path(&self, scope_prefix: &str, rest: &str) -> String {
|
||
format!(
|
||
"/v1/{}/data/{}/{}/{}",
|
||
self.mount, self.base_path, scope_prefix, rest
|
||
)
|
||
}
|
||
|
||
fn metadata_path(&self, scope_prefix: &str, rest: &str) -> String {
|
||
format!(
|
||
"/v1/{}/metadata/{}/{}/{}",
|
||
self.mount, self.base_path, scope_prefix, rest
|
||
)
|
||
}
|
||
|
||
// ── KV v2 operations ──
|
||
|
||
/// List top-level folders visible across all scopes (shared + instance).
|
||
pub async fn list_folders(&self) -> Result<Vec<FolderInfo>, VaultError> {
|
||
let mut folders = Vec::new();
|
||
|
||
for (scope_label, prefix) in self.scope_prefixes() {
|
||
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
|
||
match self.kv_list(&path).await {
|
||
Ok(keys) => {
|
||
let has_subfolders: Vec<&str> =
|
||
keys.iter().filter_map(|k| k.strip_suffix('/')).collect();
|
||
for name in &has_subfolders {
|
||
folders.push(FolderInfo {
|
||
name: name.to_string(),
|
||
description: String::new(),
|
||
scope: scope_label.to_string(),
|
||
path: Some(name.to_string()),
|
||
has_children: None, // enriched below
|
||
});
|
||
}
|
||
}
|
||
Err(VaultError::NotFound) => {
|
||
// No folders in this scope — that's fine
|
||
}
|
||
Err(e) => return Err(e),
|
||
}
|
||
}
|
||
|
||
// Enrich with descriptions and child detection
|
||
for folder in &mut folders {
|
||
if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await {
|
||
folder.description = config.description;
|
||
}
|
||
// Check for subfolders by listing children
|
||
if let Ok(children) = self.list_children(&folder.scope, &folder.name).await {
|
||
folder.has_children = Some(children.iter().any(|c| c.strip_suffix('/').is_some()));
|
||
}
|
||
}
|
||
|
||
Ok(folders)
|
||
}
|
||
|
||
/// List immediate children (subfolders and entries) at a given folder path.
|
||
/// Subfolder names end with `/` in the returned list.
|
||
pub async fn list_children(
|
||
&self,
|
||
scope: &str,
|
||
folder_path: &str,
|
||
) -> Result<Vec<String>, VaultError> {
|
||
validate_path(folder_path)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = format!("{}/", self.metadata_path(&scope_prefix, folder_path));
|
||
self.kv_list(&path).await
|
||
}
|
||
|
||
/// List subfolders at a given path within a scope.
|
||
/// Returns FolderInfo for each subfolder, with has_children populated.
|
||
pub async fn list_subfolders(
|
||
&self,
|
||
scope: &str,
|
||
parent_path: &str,
|
||
) -> Result<Vec<FolderInfo>, VaultError> {
|
||
let children = self.list_children(scope, parent_path).await?;
|
||
let mut folders = Vec::new();
|
||
|
||
for key in &children {
|
||
if let Some(name) = key.strip_suffix('/') {
|
||
let full_path = format!("{}/{}", parent_path, name);
|
||
let mut info = FolderInfo {
|
||
name: name.to_string(),
|
||
description: String::new(),
|
||
scope: scope.to_string(),
|
||
path: Some(full_path.clone()),
|
||
has_children: None,
|
||
};
|
||
// Enrich with description
|
||
if let Ok(config) = self.get_folder_config(scope, &full_path).await {
|
||
info.description = config.description;
|
||
}
|
||
// Check for grandchildren
|
||
if let Ok(grandchildren) = self.list_children(scope, &full_path).await {
|
||
info.has_children =
|
||
Some(grandchildren.iter().any(|c| c.strip_suffix('/').is_some()));
|
||
}
|
||
folders.push(info);
|
||
}
|
||
}
|
||
|
||
Ok(folders)
|
||
}
|
||
|
||
/// Get the .config for a folder in a specific scope.
|
||
pub async fn get_folder_config(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
) -> Result<FolderConfig, VaultError> {
|
||
validate_path(folder)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config"));
|
||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 => {
|
||
let json: serde_json::Value = resp.json().await?;
|
||
let data = &json["data"]["data"];
|
||
serde_json::from_value(data.clone())
|
||
.map_err(|e| VaultError::Parse(format!("invalid .config: {}", e)))
|
||
}
|
||
404 => Err(VaultError::NotFound),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!("unexpected status {}", s))),
|
||
}
|
||
}
|
||
|
||
/// Resolve whether `user_groups` grants access to `folder` under `scope`.
|
||
///
|
||
/// Checks the folder's own `allowed_groups` first. If none match and the
|
||
/// folder's `inherit_from_parent` is true, walks up the slash-separated
|
||
/// path and evaluates each ancestor's config the same way. Returns `false`
|
||
/// once a folder denies and doesn't inherit, or once the walk reaches the
|
||
/// top-level folder with no match. Missing (`NotFound`) folder configs
|
||
/// are treated as deny; other Vault errors propagate so callers can log.
|
||
pub async fn resolve_folder_access(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
user_groups: &[String],
|
||
) -> Result<bool, VaultError> {
|
||
let mut current = folder.to_string();
|
||
loop {
|
||
let config = match self.get_folder_config(scope, ¤t).await {
|
||
Ok(c) => c,
|
||
Err(VaultError::NotFound) => return Ok(false),
|
||
Err(e) => return Err(e),
|
||
};
|
||
if config
|
||
.allowed_groups
|
||
.iter()
|
||
.any(|g| user_groups.iter().any(|ug| ug == g))
|
||
{
|
||
return Ok(true);
|
||
}
|
||
if !config.inherit_from_parent {
|
||
return Ok(false);
|
||
}
|
||
// Walk to parent segment; stop at the top-level folder.
|
||
match current.rsplit_once('/') {
|
||
Some((parent, _)) if !parent.is_empty() => current = parent.to_string(),
|
||
_ => return Ok(false),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// List entry names in a folder (excludes .config).
|
||
pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
|
||
validate_path(folder)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = format!("{}/", self.metadata_path(&scope_prefix, folder));
|
||
let keys = self.kv_list(&path).await?;
|
||
Ok(keys.into_iter().filter(|k| k != ".config").collect())
|
||
}
|
||
|
||
/// Get a full entry (with credentials).
|
||
pub async fn get_entry(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
entry: &str,
|
||
) -> Result<AddressBookEntry, VaultError> {
|
||
validate_path(folder)?;
|
||
validate_name(entry)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry));
|
||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 => {
|
||
let json: serde_json::Value = resp.json().await?;
|
||
let data = &json["data"]["data"];
|
||
let mut entry: AddressBookEntry = serde_json::from_value(data.clone())
|
||
.map_err(|e| VaultError::Parse(format!("invalid entry: {}", e)))?;
|
||
entry.normalize_jump_hosts();
|
||
Ok(entry)
|
||
}
|
||
404 => Err(VaultError::NotFound),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!("unexpected status {}", s))),
|
||
}
|
||
}
|
||
|
||
/// Write an entry to Vault.
|
||
pub async fn put_entry(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
entry: &str,
|
||
data: &AddressBookEntry,
|
||
) -> Result<(), VaultError> {
|
||
validate_path(folder)?;
|
||
validate_name(entry)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, entry));
|
||
let body = serde_json::json!({ "data": data });
|
||
let resp = self
|
||
.request(reqwest::Method::POST, &path, Some(&body))
|
||
.await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 | 204 => Ok(()),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => {
|
||
let text = resp.text().await.unwrap_or_default();
|
||
Err(VaultError::Parse(format!(
|
||
"put entry failed ({}): {}",
|
||
s, text
|
||
)))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete an entry (all versions via metadata endpoint).
|
||
pub async fn delete_entry(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
entry: &str,
|
||
) -> Result<(), VaultError> {
|
||
validate_name(entry)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = self.metadata_path(&scope_prefix, &format!("{}/{}", folder, entry));
|
||
let resp = self.request(reqwest::Method::DELETE, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 | 204 => Ok(()),
|
||
404 => Err(VaultError::NotFound),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!("delete entry failed ({})", s))),
|
||
}
|
||
}
|
||
|
||
/// Write a folder's .config.
|
||
pub async fn put_folder_config(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
config: &FolderConfig,
|
||
) -> Result<(), VaultError> {
|
||
validate_path(folder)?;
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".config"));
|
||
let body = serde_json::json!({ "data": config });
|
||
let resp = self
|
||
.request(reqwest::Method::POST, &path, Some(&body))
|
||
.await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 | 204 => Ok(()),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => {
|
||
let text = resp.text().await.unwrap_or_default();
|
||
Err(VaultError::Parse(format!(
|
||
"put folder config failed ({}): {}",
|
||
s, text
|
||
)))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete an entire folder and its subtree (all entries + .config at every
|
||
/// level). Pre-v1.6.0 this only cleared the top folder, which silently
|
||
/// left subfolder keys in Vault; post-subfolders the UI would then refresh
|
||
/// and show the folder still populated with its subtree, so delete
|
||
/// appeared to silently fail. This now walks the whole subtree iteratively.
|
||
///
|
||
/// Returns (subfolder_count, entry_count) for audit/UI feedback. Subfolder
|
||
/// count excludes the folder itself (i.e. 0 for a leaf).
|
||
pub async fn delete_folder(
|
||
&self,
|
||
scope: &str,
|
||
folder: &str,
|
||
) -> Result<(usize, usize), VaultError> {
|
||
validate_path(folder)?;
|
||
|
||
// BFS-collect every folder path in the subtree (including the root).
|
||
let mut queue: Vec<String> = vec![folder.to_string()];
|
||
let mut i = 0;
|
||
while i < queue.len() {
|
||
let current = queue[i].clone();
|
||
if let Ok(subs) = self.list_subfolders(scope, ¤t).await {
|
||
for sub in subs {
|
||
let sub_path = sub
|
||
.path
|
||
.unwrap_or_else(|| format!("{}/{}", current, sub.name));
|
||
queue.push(sub_path);
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
let scope_prefix = self.resolve_scope_prefix(scope)?;
|
||
let mut entry_count = 0usize;
|
||
for path in &queue {
|
||
let entries = self.list_entries(scope, path).await.unwrap_or_default();
|
||
for entry in entries {
|
||
let _ = self.delete_entry(scope, path, &entry).await;
|
||
entry_count += 1;
|
||
}
|
||
let cfg_path = self.metadata_path(&scope_prefix, &format!("{}/{}", path, ".config"));
|
||
let _ = self.request(reqwest::Method::DELETE, &cfg_path, None).await;
|
||
}
|
||
|
||
let subfolder_count = queue.len().saturating_sub(1);
|
||
Ok((subfolder_count, entry_count))
|
||
}
|
||
|
||
// ── Generic KV v2 read ──
|
||
|
||
/// Read a single field from an arbitrary KV v2 path (relative to base_path).
|
||
/// Used for reading non-address-book secrets like the LUKS encryption key.
|
||
pub async fn read_kv_field(&self, kv_path: &str, field: &str) -> Result<String, VaultError> {
|
||
let path = format!("/v1/{}/data/{}", self.mount, kv_path);
|
||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 => {
|
||
let json: serde_json::Value = resp.json().await?;
|
||
json["data"]["data"][field]
|
||
.as_str()
|
||
.map(|s| s.to_string())
|
||
.ok_or_else(|| {
|
||
VaultError::Parse(format!("field '{}' not found in secret", field))
|
||
})
|
||
}
|
||
404 => Err(VaultError::NotFound),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!("unexpected status {}", s))),
|
||
}
|
||
}
|
||
|
||
// ── Internal helpers ──
|
||
|
||
/// Resolve "shared" or "instance" scope label to the actual Vault path prefix.
|
||
fn resolve_scope_prefix(&self, scope: &str) -> Result<String, VaultError> {
|
||
match scope {
|
||
"shared" => Ok("shared".to_string()),
|
||
"instance" => match &self.instance_name {
|
||
Some(name) => Ok(format!("instance/{}", name)),
|
||
None => Err(VaultError::BadName("no instance_name configured".into())),
|
||
},
|
||
_ => Err(VaultError::BadName(format!("invalid scope: {}", scope))),
|
||
}
|
||
}
|
||
|
||
/// Perform a LIST operation on a Vault path. Returns the keys array.
|
||
async fn kv_list(&self, path: &str) -> Result<Vec<String>, VaultError> {
|
||
// Vault LIST is a GET with ?list=true (also works with HTTP method LIST,
|
||
// but ?list=true is more portable across HTTP clients).
|
||
let url = format!("{}{}?list=true", self.addr, path);
|
||
let token = self.token.read().await.clone();
|
||
|
||
let mut req = self.http.get(&url).header("X-Vault-Token", &token);
|
||
if let Some(ref ns) = self.namespace {
|
||
req = req.header("X-Vault-Namespace", ns.as_str());
|
||
}
|
||
|
||
let resp = req.send().await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 => {
|
||
let json: serde_json::Value = resp.json().await?;
|
||
let keys = json["data"]["keys"]
|
||
.as_array()
|
||
.map(|arr| {
|
||
arr.iter()
|
||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
Ok(keys)
|
||
}
|
||
404 => Err(VaultError::NotFound),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!("list failed ({})", s))),
|
||
}
|
||
}
|
||
|
||
// ── User credential variables ──
|
||
|
||
/// Read a user's stored credential variables from Vault.
|
||
/// Path: `<base_path>/users/<sanitized_email>`
|
||
pub async fn get_user_credentials(
|
||
&self,
|
||
email: &str,
|
||
) -> Result<HashMap<String, String>, VaultError> {
|
||
let key = sanitize_email_key(email);
|
||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 => {
|
||
let json: serde_json::Value = resp.json().await?;
|
||
let data = &json["data"]["data"];
|
||
let map = data
|
||
.as_object()
|
||
.map(|obj| {
|
||
obj.iter()
|
||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
Ok(map)
|
||
}
|
||
404 => Ok(HashMap::new()), // No credentials stored yet
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!(
|
||
"get user credentials failed ({})",
|
||
s
|
||
))),
|
||
}
|
||
}
|
||
|
||
/// Write a user's credential variables to Vault (full replace).
|
||
/// Path: `<base_path>/users/<sanitized_email>`
|
||
pub async fn put_user_credentials(
|
||
&self,
|
||
email: &str,
|
||
creds: &HashMap<String, String>,
|
||
) -> Result<(), VaultError> {
|
||
let key = sanitize_email_key(email);
|
||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||
let body = serde_json::json!({ "data": creds });
|
||
let resp = self
|
||
.request(reqwest::Method::POST, &path, Some(&body))
|
||
.await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 | 204 => Ok(()),
|
||
403 => Err(VaultError::Forbidden),
|
||
s => {
|
||
let text = resp.text().await.unwrap_or_default();
|
||
Err(VaultError::Parse(format!(
|
||
"put user credentials failed ({}): {}",
|
||
s, text
|
||
)))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete a user's credential variables from Vault.
|
||
#[allow(dead_code)] // Will be used by admin endpoint
|
||
pub async fn delete_user_credentials(&self, email: &str) -> Result<(), VaultError> {
|
||
let key = sanitize_email_key(email);
|
||
let path = format!(
|
||
"/v1/{}/metadata/{}/users/{}",
|
||
self.mount, self.base_path, key
|
||
);
|
||
let resp = self.request(reqwest::Method::DELETE, &path, None).await?;
|
||
|
||
match resp.status().as_u16() {
|
||
200 | 204 => Ok(()),
|
||
404 => Ok(()), // Already gone
|
||
403 => Err(VaultError::Forbidden),
|
||
s => Err(VaultError::Parse(format!(
|
||
"delete user credentials failed ({})",
|
||
s
|
||
))),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a reqwest HTTP client from a VaultConfig (extracted for testability).
|
||
fn build_vault_http_client(config: &VaultConfig) -> Result<reqwest::Client, VaultError> {
|
||
let needs_custom_tls =
|
||
config.client_cert.is_some() || config.ca_cert.is_some() || config.tls_skip_verify;
|
||
|
||
if !needs_custom_tls {
|
||
// Simple path: no custom TLS config needed
|
||
return reqwest::Client::builder()
|
||
.build()
|
||
.map_err(|e| VaultError::Auth(format!("failed to create HTTP client: {}", e)));
|
||
}
|
||
|
||
// Ensure ring crypto provider is available (needed when building rustls ClientConfig
|
||
// directly rather than through reqwest's builder).
|
||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||
|
||
// Build a rustls ClientConfig directly — this bypasses reqwest::Identity::from_pem()
|
||
// which can fail with the rustls backend for valid PKCS#8 keys from OpenBao/Vault PKI.
|
||
let mut root_store = rustls::RootCertStore::empty();
|
||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||
|
||
// Custom CA certificate for private/self-signed CAs
|
||
if let Some(ref ca_path) = config.ca_cert {
|
||
let ca_pem = std::fs::read(ca_path)
|
||
.map_err(|e| VaultError::Auth(format!("failed to read CA cert {}: {}", ca_path, e)))?;
|
||
let ca_certs: Vec<_> = rustls_pemfile::certs(&mut ca_pem.as_slice())
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| VaultError::Auth(format!("failed to parse CA cert {}: {}", ca_path, e)))?;
|
||
if ca_certs.is_empty() {
|
||
return Err(VaultError::Auth(format!(
|
||
"no certificates found in CA file {}",
|
||
ca_path
|
||
)));
|
||
}
|
||
for cert in &ca_certs {
|
||
root_store.add(cert.clone()).map_err(|e| {
|
||
VaultError::Auth(format!("failed to add CA cert to root store: {}", e))
|
||
})?;
|
||
}
|
||
tracing::info!(
|
||
"Vault TLS: added {} CA certificate(s) from {}",
|
||
ca_certs.len(),
|
||
ca_path
|
||
);
|
||
}
|
||
|
||
let tls_config = if let Some(ref cert_path) = config.client_cert {
|
||
// mTLS: parse client cert chain + private key, build rustls config directly
|
||
let key_path = config.client_key.as_deref().ok_or_else(|| {
|
||
VaultError::Auth(
|
||
"client_cert is set but client_key is missing in [vault] config".into(),
|
||
)
|
||
})?;
|
||
let cert_pem = std::fs::read(cert_path).map_err(|e| {
|
||
VaultError::Auth(format!("failed to read client cert {}: {}", cert_path, e))
|
||
})?;
|
||
let key_pem = std::fs::read(key_path).map_err(|e| {
|
||
VaultError::Auth(format!("failed to read client key {}: {}", key_path, e))
|
||
})?;
|
||
|
||
let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_slice())
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| {
|
||
VaultError::Auth(format!(
|
||
"failed to parse certificates from {}: {}",
|
||
cert_path, e
|
||
))
|
||
})?;
|
||
if certs.is_empty() {
|
||
return Err(VaultError::Auth(format!(
|
||
"no certificates found in {}",
|
||
cert_path
|
||
)));
|
||
}
|
||
tracing::info!(
|
||
"Vault TLS: parsed {} certificate(s) from {}",
|
||
certs.len(),
|
||
cert_path
|
||
);
|
||
|
||
let private_key = rustls_pemfile::private_key(&mut key_pem.as_slice())
|
||
.map_err(|e| {
|
||
VaultError::Auth(format!(
|
||
"failed to parse private key from {}: {} \
|
||
(expected PEM: BEGIN PRIVATE KEY, BEGIN RSA PRIVATE KEY, or BEGIN EC PRIVATE KEY)",
|
||
key_path, e
|
||
))
|
||
})?
|
||
.ok_or_else(|| {
|
||
VaultError::Auth(format!(
|
||
"no private key found in {} \
|
||
(expected PEM: BEGIN PRIVATE KEY, BEGIN RSA PRIVATE KEY, or BEGIN EC PRIVATE KEY)",
|
||
key_path
|
||
))
|
||
})?;
|
||
tracing::info!(
|
||
"Vault TLS: parsed private key from {} ({} bytes DER)",
|
||
key_path,
|
||
private_key.secret_der().len()
|
||
);
|
||
|
||
let builder = if config.tls_skip_verify {
|
||
rustls::ClientConfig::builder()
|
||
.dangerous()
|
||
.with_custom_certificate_verifier(Arc::new(NoVerifier))
|
||
} else {
|
||
rustls::ClientConfig::builder().with_root_certificates(root_store)
|
||
};
|
||
|
||
builder
|
||
.with_client_auth_cert(certs, private_key)
|
||
.map_err(|e| {
|
||
VaultError::Auth(format!(
|
||
"failed to build mTLS config with {} + {}: {}",
|
||
cert_path, key_path, e
|
||
))
|
||
})?
|
||
} else {
|
||
// CA cert only (no mTLS) or tls_skip_verify
|
||
if config.tls_skip_verify {
|
||
rustls::ClientConfig::builder()
|
||
.dangerous()
|
||
.with_custom_certificate_verifier(Arc::new(NoVerifier))
|
||
.with_no_client_auth()
|
||
} else {
|
||
rustls::ClientConfig::builder()
|
||
.with_root_certificates(root_store)
|
||
.with_no_client_auth()
|
||
}
|
||
};
|
||
|
||
reqwest::Client::builder()
|
||
.use_preconfigured_tls(tls_config)
|
||
.build()
|
||
.map_err(|e| VaultError::Auth(format!("failed to create HTTP client: {}", e)))
|
||
}
|
||
|
||
/// Certificate verifier that accepts all server certificates (for tls_skip_verify).
|
||
#[derive(Debug)]
|
||
struct NoVerifier;
|
||
|
||
impl rustls::client::danger::ServerCertVerifier for NoVerifier {
|
||
fn verify_server_cert(
|
||
&self,
|
||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||
_ocsp_response: &[u8],
|
||
_now: rustls::pki_types::UnixTime,
|
||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||
}
|
||
|
||
fn verify_tls12_signature(
|
||
&self,
|
||
_message: &[u8],
|
||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||
_dss: &rustls::DigitallySignedStruct,
|
||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||
}
|
||
|
||
fn verify_tls13_signature(
|
||
&self,
|
||
_message: &[u8],
|
||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||
_dss: &rustls::DigitallySignedStruct,
|
||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||
}
|
||
|
||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||
rustls::crypto::ring::default_provider()
|
||
.signature_verification_algorithms
|
||
.supported_schemes()
|
||
}
|
||
}
|
||
|
||
/// Validate that a folder or entry name is safe (alphanumeric, hyphens, underscores, dots — no path traversal).
|
||
fn validate_name(name: &str) -> Result<(), VaultError> {
|
||
if name.is_empty() || name.len() > 64 {
|
||
return Err(VaultError::BadName("name must be 1-64 characters".into()));
|
||
}
|
||
if name == ".config" || name == "." || name == ".." {
|
||
return Err(VaultError::BadName("reserved name".into()));
|
||
}
|
||
if name.contains('/') || name.contains('\\') {
|
||
return Err(VaultError::BadName(
|
||
"name cannot contain path separators".into(),
|
||
));
|
||
}
|
||
if !name
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||
{
|
||
return Err(VaultError::BadName(
|
||
"name must be alphanumeric, hyphens, underscores, or dots".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate a folder path that may contain subfolders (e.g. "Clients/Acme/Servers").
|
||
/// Each segment is validated with the same rules as `validate_name`.
|
||
/// Empty segments, trailing slashes, and leading slashes are rejected.
|
||
fn validate_path(path: &str) -> Result<(), VaultError> {
|
||
if path.is_empty() {
|
||
return Err(VaultError::BadName("path cannot be empty".into()));
|
||
}
|
||
if path.len() > 256 {
|
||
return Err(VaultError::BadName("path too long (max 256 chars)".into()));
|
||
}
|
||
if path.starts_with('/') || path.ends_with('/') {
|
||
return Err(VaultError::BadName(
|
||
"path cannot start or end with /".into(),
|
||
));
|
||
}
|
||
if path.contains("//") {
|
||
return Err(VaultError::BadName(
|
||
"path cannot contain empty segments".into(),
|
||
));
|
||
}
|
||
for segment in path.split('/') {
|
||
validate_name(segment)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Sanitize an email address for use as a Vault path component.
|
||
/// Replaces `@` with `_at_` and strips any characters not in `[a-zA-Z0-9._-]`.
|
||
fn sanitize_email_key(email: &str) -> String {
|
||
email
|
||
.replace('@', "_at_")
|
||
.chars()
|
||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||
.collect()
|
||
}
|
||
|
||
/// Check if a string is a credential variable reference (starts with `$`).
|
||
pub fn is_credential_variable(s: &str) -> bool {
|
||
s.starts_with('$')
|
||
&& s.len() > 1
|
||
&& s[1..]
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||
}
|
||
|
||
/// Extract the variable name from a `$variable` reference.
|
||
fn variable_name(s: &str) -> Option<&str> {
|
||
if is_credential_variable(s) {
|
||
Some(&s[1..])
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Collect all credential variable names referenced by an address book entry.
|
||
pub fn entry_credential_variables(entry: &AddressBookEntry) -> Vec<String> {
|
||
[
|
||
&entry.username,
|
||
&entry.password,
|
||
&entry.domain,
|
||
&entry.private_key,
|
||
&entry.container_username,
|
||
&entry.container_password,
|
||
]
|
||
.iter()
|
||
.filter_map(|field| field.as_deref())
|
||
.filter_map(variable_name)
|
||
.map(|s| s.to_string())
|
||
.collect()
|
||
}
|
||
|
||
/// Resolve credential variable references in an address book entry.
|
||
/// Returns the entry with `$var` fields substituted from the user's credential map.
|
||
/// Fields that are not variable references are left unchanged.
|
||
/// Returns `Err(vec_of_missing_var_names)` if any referenced variables are missing.
|
||
pub fn resolve_credential_variables(
|
||
entry: &AddressBookEntry,
|
||
user_creds: &HashMap<String, String>,
|
||
) -> Result<AddressBookEntry, Vec<String>> {
|
||
let mut resolved = entry.clone();
|
||
let mut missing = Vec::new();
|
||
|
||
fn resolve_field(
|
||
field: &mut Option<String>,
|
||
creds: &HashMap<String, String>,
|
||
missing: &mut Vec<String>,
|
||
) {
|
||
if let Some(ref val) = field {
|
||
if let Some(name) = variable_name(val) {
|
||
if let Some(resolved_val) = creds.get(name) {
|
||
*field = Some(resolved_val.clone());
|
||
} else {
|
||
missing.push(name.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
resolve_field(&mut resolved.username, user_creds, &mut missing);
|
||
resolve_field(&mut resolved.password, user_creds, &mut missing);
|
||
resolve_field(&mut resolved.domain, user_creds, &mut missing);
|
||
resolve_field(&mut resolved.private_key, user_creds, &mut missing);
|
||
resolve_field(&mut resolved.container_username, user_creds, &mut missing);
|
||
resolve_field(&mut resolved.container_password, user_creds, &mut missing);
|
||
|
||
if missing.is_empty() {
|
||
Ok(resolved)
|
||
} else {
|
||
Err(missing)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn base_config() -> VaultConfig {
|
||
VaultConfig {
|
||
addr: "https://vault.example.com:8200".into(),
|
||
mount: "secret".into(),
|
||
base_path: "rustguac".into(),
|
||
role_id: "test-role-id".into(),
|
||
namespace: None,
|
||
instance_name: None,
|
||
tls_skip_verify: false,
|
||
ca_cert: None,
|
||
client_cert: None,
|
||
client_key: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_defaults() {
|
||
let config = base_config();
|
||
let client = build_vault_http_client(&config);
|
||
assert!(client.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_tls_skip_verify() {
|
||
let mut config = base_config();
|
||
config.tls_skip_verify = true;
|
||
let client = build_vault_http_client(&config);
|
||
assert!(client.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_ca_cert_missing_file() {
|
||
let mut config = base_config();
|
||
config.ca_cert = Some("/nonexistent/ca.pem".into());
|
||
let err = build_vault_http_client(&config).unwrap_err();
|
||
let msg = err.to_string();
|
||
assert!(msg.contains("failed to read CA cert"), "got: {}", msg);
|
||
assert!(msg.contains("/nonexistent/ca.pem"), "got: {}", msg);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_ca_cert_invalid_pem() {
|
||
// reqwest::Certificate::from_pem rejects PEM with valid headers but
|
||
// garbage DER content.
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-tls");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let ca_path = dir.join("bad-ca.pem");
|
||
let bad_pem =
|
||
"-----BEGIN CERTIFICATE-----\nDEFINITELYnotvalid!!!\n-----END CERTIFICATE-----\n";
|
||
std::fs::write(&ca_path, bad_pem.as_bytes()).unwrap();
|
||
|
||
let mut config = base_config();
|
||
config.ca_cert = Some(ca_path.to_str().unwrap().into());
|
||
let result = build_vault_http_client(&config);
|
||
assert!(result.is_err(), "expected error for invalid PEM");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_client_cert_without_key() {
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-tls-nokey");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let cert_path = dir.join("client.pem");
|
||
std::fs::write(&cert_path, b"placeholder").unwrap();
|
||
|
||
let mut config = base_config();
|
||
config.client_cert = Some(cert_path.to_str().unwrap().into());
|
||
// client_key intentionally None
|
||
let err = build_vault_http_client(&config).unwrap_err();
|
||
let msg = err.to_string();
|
||
assert!(msg.contains("client_key is missing"), "got: {}", msg);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_client_cert_missing_file() {
|
||
let mut config = base_config();
|
||
config.client_cert = Some("/nonexistent/client.pem".into());
|
||
config.client_key = Some("/nonexistent/client-key.pem".into());
|
||
let err = build_vault_http_client(&config).unwrap_err();
|
||
let msg = err.to_string();
|
||
assert!(msg.contains("failed to read client cert"), "got: {}", msg);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_client_key_missing_file() {
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-tls-keyfile");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let cert_path = dir.join("client.pem");
|
||
std::fs::write(&cert_path, b"placeholder cert").unwrap();
|
||
|
||
let mut config = base_config();
|
||
config.client_cert = Some(cert_path.to_str().unwrap().into());
|
||
config.client_key = Some("/nonexistent/client-key.pem".into());
|
||
let err = build_vault_http_client(&config).unwrap_err();
|
||
let msg = err.to_string();
|
||
assert!(msg.contains("failed to read client key"), "got: {}", msg);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_valid_ca_cert() {
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-tls-valid");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let ca_path = dir.join("ca.pem");
|
||
|
||
// Generate a real self-signed cert via openssl
|
||
let output = std::process::Command::new("openssl")
|
||
.args([
|
||
"req",
|
||
"-x509",
|
||
"-newkey",
|
||
"ec",
|
||
"-pkeyopt",
|
||
"ec_paramgen_curve:prime256v1",
|
||
"-keyout",
|
||
"/dev/null",
|
||
"-out",
|
||
ca_path.to_str().unwrap(),
|
||
"-days",
|
||
"1",
|
||
"-nodes",
|
||
"-subj",
|
||
"/CN=Test CA",
|
||
])
|
||
.output()
|
||
.expect("openssl must be available for this test");
|
||
assert!(output.status.success(), "openssl failed: {:?}", output);
|
||
|
||
let mut config = base_config();
|
||
config.ca_cert = Some(ca_path.to_str().unwrap().into());
|
||
let result = build_vault_http_client(&config);
|
||
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_deserialize_tls_fields() {
|
||
let toml_str = r#"
|
||
addr = "https://vault.example.com:8200"
|
||
role_id = "test-role"
|
||
ca_cert = "/opt/rustguac/certs/ca.pem"
|
||
client_cert = "/opt/rustguac/certs/client.pem"
|
||
client_key = "/opt/rustguac/certs/client-key.pem"
|
||
tls_skip_verify = true
|
||
"#;
|
||
let config: VaultConfig = toml::from_str(toml_str).unwrap();
|
||
assert_eq!(
|
||
config.ca_cert.as_deref(),
|
||
Some("/opt/rustguac/certs/ca.pem")
|
||
);
|
||
assert_eq!(
|
||
config.client_cert.as_deref(),
|
||
Some("/opt/rustguac/certs/client.pem")
|
||
);
|
||
assert_eq!(
|
||
config.client_key.as_deref(),
|
||
Some("/opt/rustguac/certs/client-key.pem")
|
||
);
|
||
assert!(config.tls_skip_verify);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_deserialize_no_tls_fields() {
|
||
let toml_str = r#"
|
||
addr = "https://vault.example.com:8200"
|
||
role_id = "test-role"
|
||
"#;
|
||
let config: VaultConfig = toml::from_str(toml_str).unwrap();
|
||
assert!(config.ca_cert.is_none());
|
||
assert!(config.client_cert.is_none());
|
||
assert!(config.client_key.is_none());
|
||
assert!(!config.tls_skip_verify);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_name_ok() {
|
||
assert!(validate_name("my-entry.v2").is_ok());
|
||
assert!(validate_name("a").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_name_rejects_traversal() {
|
||
assert!(validate_name("../etc").is_err());
|
||
assert!(validate_name("foo/bar").is_err());
|
||
assert!(validate_name(".config").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_name_rejects_empty_and_long() {
|
||
assert!(validate_name("").is_err());
|
||
assert!(validate_name(&"a".repeat(65)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_path_ok() {
|
||
assert!(validate_path("my-folder").is_ok());
|
||
assert!(validate_path("Clients/Acme").is_ok());
|
||
assert!(validate_path("Clients/Acme/Servers").is_ok());
|
||
assert!(validate_path("a/b/c/d").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_path_rejects_bad_input() {
|
||
assert!(validate_path("").is_err()); // empty
|
||
assert!(validate_path("/leading").is_err()); // leading slash
|
||
assert!(validate_path("trailing/").is_err()); // trailing slash
|
||
assert!(validate_path("a//b").is_err()); // empty segment
|
||
assert!(validate_path("a/../b").is_err()); // traversal
|
||
assert!(validate_path("a/.config/b").is_err()); // reserved name
|
||
assert!(validate_path(&format!("a/{}", "x".repeat(65))).is_err()); // segment too long
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_mtls_pkcs8_key() {
|
||
// This test reproduces issue #51: PKCS#8 keys from OpenBao should work.
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-mtls");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let cert_path = dir.join("client.pem");
|
||
let key_path = dir.join("client-key.pem");
|
||
|
||
// Generate CA
|
||
let ca_key = dir.join("ca-key.pem");
|
||
let ca_cert_path = dir.join("ca.pem");
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"req",
|
||
"-x509",
|
||
"-newkey",
|
||
"ec",
|
||
"-pkeyopt",
|
||
"ec_paramgen_curve:prime256v1",
|
||
"-keyout",
|
||
ca_key.to_str().unwrap(),
|
||
"-out",
|
||
ca_cert_path.to_str().unwrap(),
|
||
"-days",
|
||
"1",
|
||
"-nodes",
|
||
"-subj",
|
||
"/CN=Test CA",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success(), "CA gen failed");
|
||
|
||
// Generate client cert signed by CA (PKCS#8 key — OpenBao default)
|
||
let csr_path = dir.join("client.csr");
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"req",
|
||
"-new",
|
||
"-newkey",
|
||
"ec",
|
||
"-pkeyopt",
|
||
"ec_paramgen_curve:prime256v1",
|
||
"-keyout",
|
||
key_path.to_str().unwrap(),
|
||
"-out",
|
||
csr_path.to_str().unwrap(),
|
||
"-nodes",
|
||
"-subj",
|
||
"/CN=client",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success(), "CSR gen failed");
|
||
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"x509",
|
||
"-req",
|
||
"-in",
|
||
csr_path.to_str().unwrap(),
|
||
"-CA",
|
||
ca_cert_path.to_str().unwrap(),
|
||
"-CAkey",
|
||
ca_key.to_str().unwrap(),
|
||
"-CAcreateserial",
|
||
"-out",
|
||
cert_path.to_str().unwrap(),
|
||
"-days",
|
||
"1",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success(), "client cert gen failed");
|
||
|
||
// Verify the key is PKCS#8 (BEGIN PRIVATE KEY, not BEGIN EC PRIVATE KEY)
|
||
let key_pem = std::fs::read_to_string(&key_path).unwrap();
|
||
assert!(
|
||
key_pem.contains("BEGIN PRIVATE KEY"),
|
||
"expected PKCS#8 key, got: {}",
|
||
key_pem.lines().next().unwrap_or("")
|
||
);
|
||
|
||
let mut config = base_config();
|
||
config.ca_cert = Some(ca_cert_path.to_str().unwrap().into());
|
||
config.client_cert = Some(cert_path.to_str().unwrap().into());
|
||
config.client_key = Some(key_path.to_str().unwrap().into());
|
||
let result = build_vault_http_client(&config);
|
||
assert!(
|
||
result.is_ok(),
|
||
"mTLS with PKCS#8 key failed: {:?}",
|
||
result.err()
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_client_mtls_fullchain_cert() {
|
||
// Test with fullchain cert (leaf + CA) — as OpenBao typically delivers.
|
||
let dir = std::env::temp_dir().join("rustguac-test-vault-mtls-chain");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
let key_path = dir.join("client-key.pem");
|
||
let fullchain_path = dir.join("client-fullchain.pem");
|
||
|
||
// Generate CA
|
||
let ca_key = dir.join("ca-key.pem");
|
||
let ca_cert_path = dir.join("ca.pem");
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"req",
|
||
"-x509",
|
||
"-newkey",
|
||
"ec",
|
||
"-pkeyopt",
|
||
"ec_paramgen_curve:prime256v1",
|
||
"-keyout",
|
||
ca_key.to_str().unwrap(),
|
||
"-out",
|
||
ca_cert_path.to_str().unwrap(),
|
||
"-days",
|
||
"1",
|
||
"-nodes",
|
||
"-subj",
|
||
"/CN=Test CA",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success());
|
||
|
||
// Generate client cert
|
||
let csr_path = dir.join("client.csr");
|
||
let leaf_path = dir.join("client-leaf.pem");
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"req",
|
||
"-new",
|
||
"-newkey",
|
||
"ec",
|
||
"-pkeyopt",
|
||
"ec_paramgen_curve:prime256v1",
|
||
"-keyout",
|
||
key_path.to_str().unwrap(),
|
||
"-out",
|
||
csr_path.to_str().unwrap(),
|
||
"-nodes",
|
||
"-subj",
|
||
"/CN=client",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success());
|
||
|
||
let status = std::process::Command::new("openssl")
|
||
.args([
|
||
"x509",
|
||
"-req",
|
||
"-in",
|
||
csr_path.to_str().unwrap(),
|
||
"-CA",
|
||
ca_cert_path.to_str().unwrap(),
|
||
"-CAkey",
|
||
ca_key.to_str().unwrap(),
|
||
"-CAcreateserial",
|
||
"-out",
|
||
leaf_path.to_str().unwrap(),
|
||
"-days",
|
||
"1",
|
||
])
|
||
.output()
|
||
.expect("openssl needed");
|
||
assert!(status.status.success());
|
||
|
||
// Build fullchain: leaf + CA (as OpenBao delivers)
|
||
let leaf = std::fs::read_to_string(&leaf_path).unwrap();
|
||
let ca = std::fs::read_to_string(&ca_cert_path).unwrap();
|
||
std::fs::write(&fullchain_path, format!("{}{}", leaf, ca)).unwrap();
|
||
|
||
let mut config = base_config();
|
||
config.ca_cert = Some(ca_cert_path.to_str().unwrap().into());
|
||
config.client_cert = Some(fullchain_path.to_str().unwrap().into());
|
||
config.client_key = Some(key_path.to_str().unwrap().into());
|
||
let result = build_vault_http_client(&config);
|
||
assert!(
|
||
result.is_ok(),
|
||
"mTLS with fullchain cert failed: {:?}",
|
||
result.err()
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sanitize_email_key() {
|
||
assert_eq!(
|
||
sanitize_email_key("alice@example.com"),
|
||
"alice_at_example.com"
|
||
);
|
||
assert_eq!(sanitize_email_key("bob+tag@foo.co"), "bobtag_at_foo.co");
|
||
assert_eq!(sanitize_email_key("../../evil"), "....evil");
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_credential_variable() {
|
||
assert!(is_credential_variable("$corp_user"));
|
||
assert!(is_credential_variable("$lab_password"));
|
||
assert!(is_credential_variable("$x"));
|
||
assert!(!is_credential_variable("$"));
|
||
assert!(!is_credential_variable("plain_text"));
|
||
assert!(!is_credential_variable(""));
|
||
assert!(!is_credential_variable("$has spaces"));
|
||
assert!(is_credential_variable("$has-dashes")); // hyphens allowed since v0.8.0
|
||
}
|
||
|
||
#[test]
|
||
fn test_entry_credential_variables() {
|
||
let entry = AddressBookEntry {
|
||
username: Some("$corp_user".into()),
|
||
password: Some("$corp_password".into()),
|
||
domain: Some("CORP".into()), // literal, not a variable
|
||
..AddressBookEntry::default()
|
||
};
|
||
let vars = entry_credential_variables(&entry);
|
||
assert_eq!(vars, vec!["corp_user", "corp_password"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_resolve_credential_variables_success() {
|
||
let entry = AddressBookEntry {
|
||
username: Some("$corp_user".into()),
|
||
password: Some("$corp_password".into()),
|
||
domain: Some("CORP".into()),
|
||
hostname: Some("rdp.example.com".into()),
|
||
..AddressBookEntry::default()
|
||
};
|
||
|
||
let mut creds = HashMap::new();
|
||
creds.insert("corp_user".into(), "alice".into());
|
||
creds.insert("corp_password".into(), "s3cret".into());
|
||
|
||
let resolved = resolve_credential_variables(&entry, &creds).unwrap();
|
||
assert_eq!(resolved.username.as_deref(), Some("alice"));
|
||
assert_eq!(resolved.password.as_deref(), Some("s3cret"));
|
||
assert_eq!(resolved.domain.as_deref(), Some("CORP")); // unchanged
|
||
assert_eq!(resolved.hostname.as_deref(), Some("rdp.example.com")); // unchanged
|
||
}
|
||
|
||
#[test]
|
||
fn test_resolve_credential_variables_missing() {
|
||
let entry = AddressBookEntry {
|
||
username: Some("$corp_user".into()),
|
||
password: Some("$corp_password".into()),
|
||
..AddressBookEntry::default()
|
||
};
|
||
|
||
let creds = HashMap::new(); // empty
|
||
let err = resolve_credential_variables(&entry, &creds).unwrap_err();
|
||
assert_eq!(err, vec!["corp_user", "corp_password"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_resolve_credential_variables_no_variables() {
|
||
let entry = AddressBookEntry {
|
||
username: Some("alice".into()),
|
||
password: Some("literal_pass".into()),
|
||
..AddressBookEntry::default()
|
||
};
|
||
|
||
let creds = HashMap::new();
|
||
let resolved = resolve_credential_variables(&entry, &creds).unwrap();
|
||
assert_eq!(resolved.username.as_deref(), Some("alice"));
|
||
assert_eq!(resolved.password.as_deref(), Some("literal_pass"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_enable_h264_serde_roundtrip() {
|
||
// With enable_h264 set
|
||
let json = r#"{"type":"rdp","hostname":"test","enable_gfx":true,"enable_h264":true}"#;
|
||
let entry: AddressBookEntry = serde_json::from_str(json).unwrap();
|
||
assert_eq!(entry.enable_h264, Some(true));
|
||
let out = serde_json::to_string(&entry).unwrap();
|
||
assert!(out.contains("\"enable_h264\":true"));
|
||
|
||
// Without enable_h264 (defaults to None)
|
||
let json2 = r#"{"type":"rdp","hostname":"test","enable_gfx":true}"#;
|
||
let entry2: AddressBookEntry = serde_json::from_str(json2).unwrap();
|
||
assert_eq!(entry2.enable_h264, None);
|
||
|
||
// Explicit false
|
||
let json3 = r#"{"type":"rdp","hostname":"test","enable_h264":false}"#;
|
||
let entry3: AddressBookEntry = serde_json::from_str(json3).unwrap();
|
||
assert_eq!(entry3.enable_h264, Some(false));
|
||
}
|
||
|
||
// ── Path-traversal regression tests (v1.5.4 fix) ──────────────────────
|
||
// Locks down the validate_name / validate_path invariants. Any future
|
||
// relaxation of these rules (or a bug that re-opens `../` handling) must
|
||
// fail these tests, not land silently.
|
||
|
||
#[test]
|
||
fn validate_name_accepts_plain_names() {
|
||
assert!(validate_name("acme").is_ok());
|
||
assert!(validate_name("acme-prod").is_ok());
|
||
assert!(validate_name("acme_prod").is_ok());
|
||
assert!(validate_name("host.example").is_ok());
|
||
assert!(validate_name("A1b2C3").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_traversal() {
|
||
assert!(validate_name("..").is_err());
|
||
assert!(validate_name(".").is_err());
|
||
assert!(validate_name("../etc").is_err());
|
||
assert!(validate_name("foo/bar").is_err());
|
||
assert!(validate_name("foo\\bar").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_reserved() {
|
||
assert!(validate_name(".config").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_encoded_traversal() {
|
||
// Encoded forms should fail because `%` is not in the whitelist.
|
||
assert!(validate_name("%2e%2e").is_err());
|
||
assert!(validate_name("%2E%2E%2F").is_err());
|
||
assert!(validate_name("..%2F..").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_nul_and_control() {
|
||
assert!(validate_name("foo\0bar").is_err());
|
||
assert!(validate_name("foo\nbar").is_err());
|
||
assert!(validate_name("foo\tbar").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_unicode() {
|
||
// Unicode letters/digits shouldn't sneak past the ascii-alphanumeric
|
||
// whitelist (blocks homoglyph and normalization tricks).
|
||
assert!(validate_name("café").is_err());
|
||
assert!(validate_name("Ⅰ").is_err()); // Roman numeral 1
|
||
assert!(validate_name("а").is_err()); // Cyrillic 'a'
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_empty_and_overlong() {
|
||
assert!(validate_name("").is_err());
|
||
let overlong = "a".repeat(65);
|
||
assert!(validate_name(&overlong).is_err());
|
||
let at_limit = "a".repeat(64);
|
||
assert!(validate_name(&at_limit).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_name_rejects_spaces_and_special() {
|
||
assert!(validate_name("foo bar").is_err());
|
||
assert!(validate_name("foo;rm -rf").is_err());
|
||
assert!(validate_name("foo$bar").is_err());
|
||
assert!(validate_name("foo@bar").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_path_accepts_nested() {
|
||
assert!(validate_path("Clients/Acme/Servers").is_ok());
|
||
assert!(validate_path("a").is_ok());
|
||
assert!(validate_path("a/b").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_path_rejects_leading_or_trailing_slash() {
|
||
assert!(validate_path("/foo").is_err());
|
||
assert!(validate_path("foo/").is_err());
|
||
assert!(validate_path("/").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_path_rejects_empty_segments() {
|
||
assert!(validate_path("").is_err());
|
||
assert!(validate_path("a//b").is_err());
|
||
assert!(validate_path("a///b").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_path_rejects_traversal_in_any_segment() {
|
||
assert!(validate_path("../etc").is_err());
|
||
assert!(validate_path("foo/../etc").is_err());
|
||
assert!(validate_path("foo/..").is_err());
|
||
assert!(validate_path("foo/./bar").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_path_rejects_overlong() {
|
||
let overlong = "a".repeat(257);
|
||
assert!(validate_path(&overlong).is_err());
|
||
}
|
||
}
|