mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
v0.6.1: Vault mTLS support, comprehensive test suite (87 tests)
Vault/OpenBao mTLS: - Add ca_cert, client_cert, client_key fields to [vault] config - Supports custom CA certificates and mutual TLS authentication - Clear error messages for missing files, invalid PEM, missing key - Fixes #48 (OpenBao requiring client certificates) Test suite (8 → 87 tests): - vault: 13 tests (TLS client builder, config deserialization, name validation) - auth: 11 tests (role hierarchy, effective role capping, XFF/trusted proxy, has_role) - session: 12 tests (CIDR network checks, autofill JSON parsing, placeholder substitution) - browser: 8 tests (Chromium password encryption, Login Data SQLite, RangeAllocator) - config: 8 tests (preset resolution, theme overrides, defaults, vault config) - api: 6 tests (HTML escaping, recording name path traversal protection) - db: 7 tests (SHA-256 hashing, key generation, user groups parsing) - import: 12 tests (already existed, now wired into module tree) - protocol: 8 tests (already existed) Other: - Wire import.rs into module tree (fixes orphaned tests) - Document mTLS config in configuration.md and integrations.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -3015,7 +3015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustguac"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustguac"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
description = "Lightweight Rust replacement for Apache Guacamole client"
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ Enables the Vault-backed address book. Requires `VAULT_SECRET_ID` environment va
|
||||
| `base_path` | `rustguac` | Base path under the mount |
|
||||
| `namespace` | — | Vault Enterprise / OpenBao namespace |
|
||||
| `instance_name` | — | Instance name for instance-scoped entries |
|
||||
| `tls_skip_verify` | `false` | Skip TLS certificate verification (dev only) |
|
||||
| `ca_cert` | — | Path to custom CA certificate (PEM) for verifying the Vault server |
|
||||
| `client_cert` | — | Path to client certificate (PEM) for mTLS |
|
||||
| `client_key` | — | Path to client private key (PEM) for mTLS (required if `client_cert` is set) |
|
||||
|
||||
## `[drive]` section
|
||||
|
||||
|
||||
@@ -174,6 +174,35 @@ echo 'VAULT_SECRET_ID=<secret_id>' > /opt/rustguac/env
|
||||
chmod 600 /opt/rustguac/env
|
||||
```
|
||||
|
||||
### mTLS (client certificate authentication)
|
||||
|
||||
If your Vault or OpenBao server requires mutual TLS (client certificates), add the certificate paths to the `[vault]` section:
|
||||
|
||||
```toml
|
||||
[vault]
|
||||
addr = "https://openbao.example.com:8200"
|
||||
role_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
ca_cert = "/opt/rustguac/certs/vault-ca.pem"
|
||||
client_cert = "/opt/rustguac/certs/vault-client.pem"
|
||||
client_key = "/opt/rustguac/certs/vault-client-key.pem"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `ca_cert` | Custom CA certificate (PEM) for verifying the Vault server. Use this when Vault uses a private or self-signed CA. |
|
||||
| `client_cert` | Client certificate (PEM) presented to Vault for mTLS. |
|
||||
| `client_key` | Client private key (PEM). Required when `client_cert` is set. |
|
||||
|
||||
Ensure the certificate files are readable by the `rustguac` system user and have restrictive permissions:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/rustguac/certs
|
||||
cp ca.pem client.pem client-key.pem /opt/rustguac/certs/
|
||||
chown rustguac:rustguac /opt/rustguac/certs/*
|
||||
chmod 600 /opt/rustguac/certs/client-key.pem
|
||||
chmod 644 /opt/rustguac/certs/ca.pem /opt/rustguac/certs/client.pem
|
||||
```
|
||||
|
||||
### KV v2 path structure
|
||||
|
||||
| Path | Description |
|
||||
|
||||
+60
@@ -2500,3 +2500,63 @@ fn html_escape(s: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_html_escape_special_chars() {
|
||||
assert_eq!(html_escape("<script>"), "<script>");
|
||||
assert_eq!(html_escape("a&b"), "a&b");
|
||||
assert_eq!(html_escape(r#"x"y"#), "x"y");
|
||||
assert_eq!(html_escape("it's"), "it's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_escape_passthrough() {
|
||||
assert_eq!(html_escape("hello world"), "hello world");
|
||||
assert_eq!(html_escape(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_escape_multiple() {
|
||||
assert_eq!(
|
||||
html_escape(r#"<a href="x">&</a>"#),
|
||||
"<a href="x">&</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_recording_name_valid() {
|
||||
assert!(is_safe_recording_name(
|
||||
"session-abc123.guac",
|
||||
Path::new("/tmp")
|
||||
));
|
||||
assert!(is_safe_recording_name(
|
||||
"2024-01-01_recording.guac",
|
||||
Path::new("/recordings")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_recording_name_no_guac_extension() {
|
||||
assert!(!is_safe_recording_name("session.mp4", Path::new("/tmp")));
|
||||
assert!(!is_safe_recording_name("session", Path::new("/tmp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_recording_name_path_traversal() {
|
||||
assert!(!is_safe_recording_name(
|
||||
"../etc/passwd.guac",
|
||||
Path::new("/tmp")
|
||||
));
|
||||
assert!(!is_safe_recording_name("foo/bar.guac", Path::new("/tmp")));
|
||||
assert!(!is_safe_recording_name("foo\\bar.guac", Path::new("/tmp")));
|
||||
assert!(!is_safe_recording_name(
|
||||
"..%2F..%2Fetc.guac",
|
||||
Path::new("/tmp")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -394,3 +394,104 @@ pub async fn optional_auth(
|
||||
// No credentials — pass through without identity
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_role_level_hierarchy() {
|
||||
assert_eq!(role_level("admin"), 4);
|
||||
assert_eq!(role_level("poweruser"), 3);
|
||||
assert_eq!(role_level("operator"), 2);
|
||||
assert_eq!(role_level("viewer"), 1);
|
||||
assert_eq!(role_level("unknown"), 0);
|
||||
assert_eq!(role_level(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_level_ordering() {
|
||||
assert!(role_level("admin") > role_level("poweruser"));
|
||||
assert!(role_level("poweruser") > role_level("operator"));
|
||||
assert!(role_level("operator") > role_level("viewer"));
|
||||
assert!(role_level("viewer") > role_level("garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_effective_role_no_cap() {
|
||||
assert_eq!(compute_effective_role("admin", &None), "admin");
|
||||
assert_eq!(compute_effective_role("viewer", &None), "viewer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_effective_role_capped() {
|
||||
let cap = Some("operator".into());
|
||||
assert_eq!(compute_effective_role("admin", &cap), "operator");
|
||||
assert_eq!(compute_effective_role("poweruser", &cap), "operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_effective_role_cap_higher_than_user() {
|
||||
let cap = Some("admin".into());
|
||||
assert_eq!(compute_effective_role("viewer", &cap), "viewer");
|
||||
assert_eq!(compute_effective_role("operator", &cap), "operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_effective_role_same_level() {
|
||||
let cap = Some("operator".into());
|
||||
assert_eq!(compute_effective_role("operator", &cap), "operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_ip_no_proxies() {
|
||||
let headers = HeaderMap::new();
|
||||
let ip = client_ip(&headers, "10.0.0.1".parse().unwrap(), &[]);
|
||||
assert_eq!(ip.to_string(), "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_ip_xff_trusted_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.50, 10.0.0.1".parse().unwrap());
|
||||
let proxies = vec!["10.0.0.0/8".into()];
|
||||
let ip = client_ip(&headers, "10.0.0.1".parse().unwrap(), &proxies);
|
||||
assert_eq!(ip.to_string(), "203.0.113.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_ip_xff_untrusted_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.50".parse().unwrap());
|
||||
let proxies = vec!["10.0.0.0/8".into()];
|
||||
// Socket is NOT in trusted range
|
||||
let ip = client_ip(&headers, "192.168.1.1".parse().unwrap(), &proxies);
|
||||
assert_eq!(ip.to_string(), "192.168.1.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_ip_xff_invalid_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "not-an-ip".parse().unwrap());
|
||||
let proxies = vec!["10.0.0.0/8".into()];
|
||||
let ip = client_ip(&headers, "10.0.0.1".parse().unwrap(), &proxies);
|
||||
// Falls back to socket addr when XFF can't be parsed
|
||||
assert_eq!(ip.to_string(), "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_role() {
|
||||
let admin = AuthIdentity::ApiKey("admin".into());
|
||||
assert!(admin.has_role("viewer"));
|
||||
assert!(admin.has_role("admin"));
|
||||
|
||||
let viewer = AuthIdentity::User {
|
||||
email: "test@test.com".into(),
|
||||
role: "viewer".into(),
|
||||
groups: vec![],
|
||||
};
|
||||
assert!(viewer.has_role("viewer"));
|
||||
assert!(!viewer.has_role("operator"));
|
||||
assert!(!viewer.has_role("admin"));
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -747,3 +747,113 @@ impl std::fmt::Display for BrowserError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_chromium_password_v10_prefix() {
|
||||
let blob = encrypt_chromium_password("secret").unwrap();
|
||||
assert_eq!(&blob[..3], b"v10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_chromium_password_deterministic() {
|
||||
let a = encrypt_chromium_password("test123").unwrap();
|
||||
let b = encrypt_chromium_password("test123").unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_chromium_password_different_inputs() {
|
||||
let a = encrypt_chromium_password("password1").unwrap();
|
||||
let b = encrypt_chromium_password("password2").unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_chromium_password_block_aligned() {
|
||||
// AES-128-CBC with PKCS7: output is always multiple of 16 bytes
|
||||
let blob = encrypt_chromium_password("short").unwrap();
|
||||
let ciphertext_len = blob.len() - 3; // minus "v10" prefix
|
||||
assert_eq!(ciphertext_len % 16, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_chromium_password_empty() {
|
||||
let blob = encrypt_chromium_password("").unwrap();
|
||||
assert_eq!(&blob[..3], b"v10");
|
||||
// Empty plaintext + PKCS7 padding = one full block
|
||||
assert_eq!(blob.len(), 3 + 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_login_data_creates_db() {
|
||||
let dir = std::env::temp_dir().join("rustguac-test-login-data");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let creds = vec![(
|
||||
"https://example.com".into(),
|
||||
"alice".into(),
|
||||
"secret".into(),
|
||||
)];
|
||||
populate_login_data(&dir, &creds).unwrap();
|
||||
|
||||
let db_path = dir.join("Default/Login Data");
|
||||
assert!(db_path.exists(), "Login Data SQLite should be created");
|
||||
|
||||
// Verify the database is valid SQLite and has data
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM logins", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let origin: String = conn
|
||||
.query_row("SELECT origin_url FROM logins", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(origin, "https://example.com/");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_login_data_multiple_creds() {
|
||||
let dir = std::env::temp_dir().join("rustguac-test-login-data-multi");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let creds = vec![
|
||||
("https://app.com".into(), "user1".into(), "pass1".into()),
|
||||
("https://idp.com".into(), "user2".into(), "pass2".into()),
|
||||
];
|
||||
populate_login_data(&dir, &creds).unwrap();
|
||||
|
||||
let db_path = dir.join("Default/Login Data");
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM logins", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_range_allocator() {
|
||||
let alloc = RangeAllocator::new(100, 102);
|
||||
let a = alloc.allocate().unwrap();
|
||||
let b = alloc.allocate().unwrap();
|
||||
let c = alloc.allocate().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(b, c);
|
||||
// Pool exhausted
|
||||
assert!(alloc.allocate().is_none());
|
||||
// Release one and re-allocate
|
||||
alloc.release(b);
|
||||
let d = alloc.allocate().unwrap();
|
||||
assert_eq!(d, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ pub struct VaultConfig {
|
||||
/// Only use this for development with self-signed certificates.
|
||||
#[serde(default)]
|
||||
pub tls_skip_verify: bool,
|
||||
/// Path to a custom CA certificate (PEM) for verifying the Vault server.
|
||||
/// Use this when Vault/OpenBao uses a private or self-signed CA.
|
||||
pub ca_cert: Option<String>,
|
||||
/// Path to a client certificate (PEM) for mTLS authentication to Vault.
|
||||
pub client_cert: Option<String>,
|
||||
/// Path to the client private key (PEM) for mTLS authentication to Vault.
|
||||
pub client_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -883,3 +890,87 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builtin_presets_exist() {
|
||||
let presets = builtin_presets();
|
||||
assert!(presets.len() >= 6, "expected at least 6 presets");
|
||||
let names: Vec<&str> = presets.iter().map(|(n, _)| *n).collect();
|
||||
assert!(names.contains(&"dark"));
|
||||
assert!(names.contains(&"light"));
|
||||
assert!(names.contains(&"high-contrast"));
|
||||
assert!(names.contains(&"terminal"));
|
||||
assert!(names.contains(&"nord"));
|
||||
assert!(names.contains(&"corporate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_presets_no_empty_colors() {
|
||||
for (name, colors) in builtin_presets() {
|
||||
assert!(!colors.primary.is_empty(), "{} has empty primary", name);
|
||||
assert!(!colors.bg.is_empty(), "{} has empty bg", name);
|
||||
assert!(!colors.text.is_empty(), "{} has empty text", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_resolve_default_preset() {
|
||||
let cfg: ThemeConfig = toml::from_str("").unwrap();
|
||||
let (name, colors) = cfg.resolve();
|
||||
assert_eq!(name, "dark");
|
||||
assert!(!colors.primary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_resolve_named_preset() {
|
||||
let cfg: ThemeConfig = toml::from_str(r#"preset = "light""#).unwrap();
|
||||
let (name, _) = cfg.resolve();
|
||||
assert_eq!(name, "light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_resolve_override() {
|
||||
let cfg: ThemeConfig =
|
||||
toml::from_str("preset = \"dark\"\nprimary_color = \"#ff0000\"").unwrap();
|
||||
let (_, colors) = cfg.resolve();
|
||||
assert_eq!(colors.primary, "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_resolve_unknown_preset_falls_back() {
|
||||
let cfg: ThemeConfig = toml::from_str(r#"preset = "nonexistent""#).unwrap();
|
||||
let (name, colors) = cfg.resolve();
|
||||
assert_eq!(name, "nonexistent");
|
||||
// Falls back to first preset (dark)
|
||||
let dark = &builtin_presets()[0].1;
|
||||
assert_eq!(colors.primary, dark.primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_defaults() {
|
||||
assert_eq!(default_listen_addr(), "127.0.0.1:8089");
|
||||
assert_eq!(default_guacd_addr(), "127.0.0.1:4822");
|
||||
assert_eq!(default_display_range_start(), 100);
|
||||
assert_eq!(default_display_range_end(), 199);
|
||||
assert_eq!(default_cdp_port_range_start(), 9200);
|
||||
assert_eq!(default_cdp_port_range_end(), 9299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_config_deserialize_minimal() {
|
||||
let toml_str = r#"
|
||||
addr = "https://vault:8200"
|
||||
role_id = "test"
|
||||
"#;
|
||||
let config: VaultConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.addr, "https://vault:8200");
|
||||
assert_eq!(config.mount, "secret");
|
||||
assert_eq!(config.base_path, "rustguac");
|
||||
assert!(!config.tls_skip_verify);
|
||||
assert!(config.ca_cert.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,3 +919,83 @@ pub fn resolve_role_from_groups(db: &Db, groups: &[String]) -> rusqlite::Result<
|
||||
|
||||
Ok(best_role)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_key_sha256() {
|
||||
let hash = hash_key("test-api-key");
|
||||
assert_eq!(hash.len(), 64); // SHA-256 = 64 hex chars
|
||||
// Deterministic
|
||||
assert_eq!(hash, hash_key("test-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_key_different_inputs() {
|
||||
assert_ne!(hash_key("key-a"), hash_key("key-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_key_format() {
|
||||
let key = generate_key();
|
||||
assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars
|
||||
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_key_unique() {
|
||||
let a = generate_key();
|
||||
let b = generate_key();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_groups_vec() {
|
||||
let user = User {
|
||||
id: 1,
|
||||
oidc_subject: None,
|
||||
created_at: "2025-01-01".into(),
|
||||
last_login_at: None,
|
||||
email: "test@test.com".into(),
|
||||
name: "test".into(),
|
||||
role: "viewer".into(),
|
||||
disabled: false,
|
||||
oidc_groups: "admins,developers,ops".into(),
|
||||
};
|
||||
assert_eq!(user.groups_vec(), vec!["admins", "developers", "ops"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_groups_vec_empty() {
|
||||
let user = User {
|
||||
id: 1,
|
||||
oidc_subject: None,
|
||||
created_at: "2025-01-01".into(),
|
||||
last_login_at: None,
|
||||
email: "test@test.com".into(),
|
||||
name: "test".into(),
|
||||
role: "viewer".into(),
|
||||
disabled: false,
|
||||
oidc_groups: String::new(),
|
||||
};
|
||||
assert!(user.groups_vec().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_groups_vec_single() {
|
||||
let user = User {
|
||||
id: 1,
|
||||
oidc_subject: None,
|
||||
created_at: "2025-01-01".into(),
|
||||
last_login_at: None,
|
||||
email: "test@test.com".into(),
|
||||
name: "test".into(),
|
||||
role: "viewer".into(),
|
||||
disabled: false,
|
||||
oidc_groups: "solo-group".into(),
|
||||
};
|
||||
assert_eq!(user.groups_vec(), vec!["solo-group"]);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-8
@@ -128,14 +128,20 @@ pub async fn cmd_import_guacamole(
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
println!("\n[DRY RUN] Would import to folder \"{}\" (scope: {}):\n", folder, scope);
|
||||
println!(
|
||||
"\n[DRY RUN] Would import to folder \"{}\" (scope: {}):\n",
|
||||
folder, scope
|
||||
);
|
||||
for (name, entry) in &entries {
|
||||
println!(
|
||||
" {} ({}) → {}:{}",
|
||||
name,
|
||||
entry.session_type,
|
||||
entry.hostname.as_deref().unwrap_or("?"),
|
||||
entry.port.map(|p| p.to_string()).unwrap_or_else(|| "?".into()),
|
||||
entry
|
||||
.port
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "?".into()),
|
||||
);
|
||||
if let Some(ref dn) = entry.display_name {
|
||||
if dn != name {
|
||||
@@ -177,7 +183,10 @@ pub async fn cmd_import_guacamole(
|
||||
allowed_groups: vec![],
|
||||
description: "Imported from Guacamole".to_string(),
|
||||
};
|
||||
if let Err(e) = client.put_folder_config(scope, folder, &folder_config).await {
|
||||
if let Err(e) = client
|
||||
.put_folder_config(scope, folder, &folder_config)
|
||||
.await
|
||||
{
|
||||
eprintln!("Error creating folder \"{}\": {}", folder, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -268,7 +277,10 @@ fn parse_parameters(sql: &str) -> HashMap<i64, Vec<(String, String)>> {
|
||||
};
|
||||
let param_name = unescape_sql(&vals[1]);
|
||||
let param_value = unescape_sql(&vals[2]);
|
||||
results.entry(id).or_default().push((param_name, param_value));
|
||||
results
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.push((param_name, param_value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,7 +581,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_tuples() {
|
||||
let line = "INSERT INTO `guacamole_connection` VALUES (1,'web',NULL,'ssh'),(2,'db',1,'rdp');";
|
||||
let line =
|
||||
"INSERT INTO `guacamole_connection` VALUES (1,'web',NULL,'ssh'),(2,'db',1,'rdp');";
|
||||
let tuples = extract_tuples(line);
|
||||
assert_eq!(tuples.len(), 2);
|
||||
assert_eq!(tuples[0], "1,'web',NULL,'ssh'");
|
||||
@@ -675,9 +688,21 @@ mod tests {
|
||||
#[test]
|
||||
fn test_group_path_nesting() {
|
||||
let groups = vec![
|
||||
Group { id: 1, parent_id: None, name: "Production".into() },
|
||||
Group { id: 2, parent_id: Some(1), name: "DMZ".into() },
|
||||
Group { id: 3, parent_id: Some(2), name: "Web".into() },
|
||||
Group {
|
||||
id: 1,
|
||||
parent_id: None,
|
||||
name: "Production".into(),
|
||||
},
|
||||
Group {
|
||||
id: 2,
|
||||
parent_id: Some(1),
|
||||
name: "DMZ".into(),
|
||||
},
|
||||
Group {
|
||||
id: 3,
|
||||
parent_id: Some(2),
|
||||
name: "Web".into(),
|
||||
},
|
||||
];
|
||||
let paths = build_group_paths(&groups);
|
||||
assert_eq!(paths[&1], "Production");
|
||||
|
||||
@@ -5,6 +5,7 @@ mod config;
|
||||
mod db;
|
||||
mod drive;
|
||||
mod guacd;
|
||||
mod import;
|
||||
mod oidc;
|
||||
mod protocol;
|
||||
mod recording;
|
||||
|
||||
+109
@@ -1203,6 +1203,115 @@ pub enum SessionError {
|
||||
BrowserSpawn(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_check_allowed_network_ipv4_match() {
|
||||
assert!(check_allowed_network("127.0.0.1", 22, &["127.0.0.0/8".into()]).is_ok());
|
||||
assert!(check_allowed_network("10.1.2.3", 80, &["10.0.0.0/8".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_allowed_network_ipv4_denied() {
|
||||
let err = check_allowed_network("8.8.8.8", 22, &["127.0.0.0/8".into()]);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_allowed_network_empty_allowlist() {
|
||||
let err = check_allowed_network("127.0.0.1", 22, &[]);
|
||||
assert!(err.is_err());
|
||||
let msg = format!("{}", err.unwrap_err());
|
||||
assert!(msg.contains("no valid CIDR"), "got: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_allowed_network_multiple_cidrs() {
|
||||
let cidrs = vec!["10.0.0.0/8".into(), "192.168.0.0/16".into()];
|
||||
assert!(check_allowed_network("10.1.1.1", 22, &cidrs).is_ok());
|
||||
assert!(check_allowed_network("192.168.1.1", 22, &cidrs).is_ok());
|
||||
assert!(check_allowed_network("172.16.0.1", 22, &cidrs).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_allowed_network_localhost_resolves() {
|
||||
// "localhost" should resolve to 127.0.0.1 or ::1
|
||||
let cidrs = vec!["127.0.0.0/8".into(), "::1/128".into()];
|
||||
assert!(check_allowed_network("localhost", 80, &cidrs).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_none() {
|
||||
assert!(parse_autofill_credentials(None, None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_empty_string() {
|
||||
assert!(parse_autofill_credentials(Some(""), None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_invalid_json() {
|
||||
assert!(parse_autofill_credentials(Some("not json"), None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_empty_array() {
|
||||
assert!(parse_autofill_credentials(Some("[]"), None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_basic() {
|
||||
let json = r#"[{"url":"https://example.com","username":"alice","password":"secret"}]"#;
|
||||
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
|
||||
assert_eq!(creds.len(), 1);
|
||||
assert_eq!(creds[0].0, "https://example.com");
|
||||
assert_eq!(creds[0].1, "alice");
|
||||
assert_eq!(creds[0].2, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_placeholder_substitution() {
|
||||
let json = r#"[{"url":"https://ex.com","username":"$USERNAME","password":"$PASSWORD"}]"#;
|
||||
let creds = parse_autofill_credentials(Some(json), Some("bob"), Some("pass123")).unwrap();
|
||||
assert_eq!(creds[0].1, "bob");
|
||||
assert_eq!(creds[0].2, "pass123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_placeholder_no_credentials() {
|
||||
// Placeholders with no username/password should substitute empty strings
|
||||
let json = r#"[{"url":"https://ex.com","username":"$USERNAME","password":"$PASSWORD"}]"#;
|
||||
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
|
||||
assert_eq!(creds[0].1, "");
|
||||
assert_eq!(creds[0].2, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_multiple_entries() {
|
||||
let json = r#"[
|
||||
{"url":"https://app.com","username":"$USERNAME","password":"$PASSWORD"},
|
||||
{"url":"https://idp.com","username":"$USERNAME","password":"$PASSWORD"}
|
||||
]"#;
|
||||
let creds = parse_autofill_credentials(Some(json), Some("alice"), Some("secret")).unwrap();
|
||||
assert_eq!(creds.len(), 2);
|
||||
assert_eq!(creds[0].0, "https://app.com");
|
||||
assert_eq!(creds[1].0, "https://idp.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_autofill_missing_fields_skipped() {
|
||||
// Entries missing required fields are silently skipped
|
||||
let json =
|
||||
r#"[{"url":"https://ex.com"},{"url":"https://ok.com","username":"a","password":"b"}]"#;
|
||||
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
|
||||
assert_eq!(creds.len(), 1);
|
||||
assert_eq!(creds[0].0, "https://ok.com");
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
|
||||
+248
-4
@@ -297,10 +297,7 @@ impl VaultClient {
|
||||
"Vault TLS certificate verification is DISABLED (tls_skip_verify = true)"
|
||||
);
|
||||
}
|
||||
let http = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(config.tls_skip_verify)
|
||||
.build()
|
||||
.map_err(|e| VaultError::Auth(format!("failed to create HTTP client: {}", e)))?;
|
||||
let http = build_vault_http_client(config)?;
|
||||
|
||||
let client = Self {
|
||||
http,
|
||||
@@ -754,6 +751,50 @@ impl VaultClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reqwest HTTP client from a VaultConfig (extracted for testability).
|
||||
fn build_vault_http_client(config: &VaultConfig) -> Result<reqwest::Client, VaultError> {
|
||||
let mut builder =
|
||||
reqwest::Client::builder().danger_accept_invalid_certs(config.tls_skip_verify);
|
||||
|
||||
// Custom CA certificate for private/self-signed CAs
|
||||
if let Some(ref ca_path) = config.ca_cert {
|
||||
let pem = std::fs::read(ca_path)
|
||||
.map_err(|e| VaultError::Auth(format!("failed to read CA cert {}: {}", ca_path, e)))?;
|
||||
let cert = reqwest::Certificate::from_pem(&pem)
|
||||
.map_err(|e| VaultError::Auth(format!("failed to parse CA cert {}: {}", ca_path, e)))?;
|
||||
builder = builder.add_root_certificate(cert);
|
||||
tracing::info!("Vault TLS: using custom CA certificate from {}", ca_path);
|
||||
}
|
||||
|
||||
// Client certificate for mTLS
|
||||
if let Some(ref cert_path) = config.client_cert {
|
||||
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 mut identity_pem = cert_pem;
|
||||
identity_pem.extend_from_slice(&key_pem);
|
||||
let identity = reqwest::Identity::from_pem(&identity_pem)
|
||||
.map_err(|e| VaultError::Auth(format!("failed to parse client identity: {}", e)))?;
|
||||
builder = builder.identity(identity);
|
||||
tracing::info!(
|
||||
"Vault TLS: using client certificate from {} (mTLS)",
|
||||
cert_path
|
||||
);
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| VaultError::Auth(format!("failed to create HTTP client: {}", e)))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -777,3 +818,206 @@ fn validate_name(name: &str) -> Result<(), VaultError> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user