diff --git a/Cargo.lock b/Cargo.lock index 013842d..bfc487a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3015,7 +3015,7 @@ dependencies = [ [[package]] name = "rustguac" -version = "0.7.2" +version = "0.8.0" dependencies = [ "aes", "axum", diff --git a/Cargo.toml b/Cargo.toml index a451e00..ff897c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustguac" -version = "0.7.2" +version = "0.8.0" edition = "2021" description = "Lightweight Rust replacement for Apache Guacamole client" diff --git a/Dockerfile b/Dockerfile index e90f4e0..3048b1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,9 @@ RUN /build/guacamole-server/configure \ --disable-guaclog \ --disable-static \ && make -j"$(nproc)" \ - && make install + && make install \ + && mkdir -p /opt/rustguac/lib/freerdp3 \ + && find /usr/lib -path "*/freerdp3/libguac*" -exec cp {} /opt/rustguac/lib/freerdp3/ \; # --------------------------------------------------------------------------- # Stage 2: Build rustguac @@ -87,7 +89,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libfreerdp3-3 libfreerdp-client3-3 libwinpr3-3 \ # Xvnc + Chromium for web browser sessions tigervnc-standalone-server \ - chromium \ + chromium chromium-sandbox \ x11-utils \ # Minimal runtime utilities ca-certificates \ @@ -106,6 +108,16 @@ COPY static/ /opt/rustguac/static/ # Library path for guacd RUN echo "/opt/rustguac/lib" > /etc/ld.so.conf.d/rustguac.conf && ldconfig +# Symlink FreeRDP plugins (RDPDR/drive, audio) into the system FreeRDP plugin dir. +# guacamole-server builds these plugins but installs them relative to the system +# FreeRDP path — we copy them under our prefix in the builder, then symlink here. +RUN FREERDP_DIR=$(find /usr/lib -name "freerdp3" -type d 2>/dev/null | head -1) && \ + if [ -n "$FREERDP_DIR" ] && [ -d /opt/rustguac/lib/freerdp3 ]; then \ + for f in /opt/rustguac/lib/freerdp3/*.so*; do \ + ln -sf "$f" "$FREERDP_DIR/$(basename "$f")"; \ + done; \ + fi + # Create writable runtime directories RUN mkdir -p /opt/rustguac/data /opt/rustguac/recordings /opt/rustguac/tls \ /opt/rustguac/certs /opt/rustguac/drives /opt/rustguac/scripts @@ -174,7 +186,8 @@ fi # Start guacd in background echo "Starting guacd..." -LD_LIBRARY_PATH=/opt/rustguac/lib /opt/rustguac/sbin/guacd \ +LD_LIBRARY_PATH=/opt/rustguac/lib FREERDP_ADDIN_PATH=/opt/rustguac/lib/freerdp3 \ + /opt/rustguac/sbin/guacd \ -b 127.0.0.1 -l 4822 -L "${GUACD_LOG_LEVEL:-info}" -f \ -C /opt/rustguac/tls/cert.pem -K /opt/rustguac/tls/key.pem & GUACD_PID=$! diff --git a/build.rs b/build.rs index 42e22db..8109a84 100644 --- a/build.rs +++ b/build.rs @@ -8,6 +8,7 @@ const DOC_FILES: &[&str] = &[ "overview.md", "installation.md", "configuration.md", + "credential-variables.md", "web-sessions.md", "security.md", "roles-and-access-control.md", diff --git a/docs/credential-variables.md b/docs/credential-variables.md new file mode 100644 index 0000000..1ce82c3 --- /dev/null +++ b/docs/credential-variables.md @@ -0,0 +1,85 @@ +# Credential Variables + +Credential variables let address book entries reference shared credentials by name instead of storing passwords directly. Users maintain their own credential values in Vault via the **My Credentials** dialog (gear menu). When a session launches, rustguac substitutes the variables from the user's saved values. + +This gives a similar experience to LDAP credential passthrough in Apache Guacamole — users log in once and sessions just work — without rustguac needing to bind to LDAP. Credentials stay in Vault, never on disk or in the browser. + +## How it works + +1. **Admin** creates address book entries with variable references like `$corp_username` and `$corp_password` in the credential fields +2. **Users** open **My Credentials** from the gear menu and fill in their values (stored per-user in Vault) +3. **At connect time**, rustguac substitutes the variables. If all are set, the session launches silently. If any are missing, the user is prompted. + +## Variable naming + +Variables start with `$` and use the pattern `$_`: + +| Pattern | Purpose | Input type | +|---------|---------|------------| +| `$_username` | Username | Text | +| `$_password` | Password | Password (masked) | +| `$_domain` | AD/Windows domain | Text | +| `$_key` | SSH private key | Textarea | + +The `` is a logical name chosen by the admin to group related credentials — for example `corp`, `jumpcloud`, `lab`, or `cloud-prod`. Multiple entries can reference the same domain, so users only configure their credentials once. + +**Allowed characters:** lowercase letters, numbers, underscores, and hyphens. For example: `$corp_username`, `$jump-host_password`, `$cloud-prod_key`. + +## Example + +An admin creates two address book entries: + +- **Production SSH** — username: `$corp_username`, password: `$corp_password` +- **Staging SSH** — username: `$corp_username`, password: `$corp_password` + +Both reference the same `corp` domain. A user opens **My Credentials**, fills in their `corp` username and password once, and both entries work without further prompting. + +An entry can also mix variables with static values. For example, an RDP entry might have a static hostname and port but use `$ad_username`, `$ad_password`, and `$ad_domain` for credentials. + +## My Credentials dialog + +Access via the gear icon in the top-right corner of the address book page. The dialog: + +- Shows all credential variables used across entries the user has access to +- Groups variables by domain prefix +- Indicates how many entries use each variable +- Masks password and key fields (saved values are not shown, but a placeholder confirms they exist) +- Partial saves work — fill in what you have now, come back later for the rest + +## Graceful degradation + +- **All variables set** — session launches immediately, no prompting +- **Some missing** — credential prompt appears with known values pre-filled; user only needs to fill gaps +- **None set** — full credential prompt (same as entries without variables) + +## Vault storage + +User credentials are stored in Vault KV v2 at: + +``` +/users/ +``` + +Each user gets a single Vault secret containing all their credential key-value pairs. Variable names are the keys, plaintext values are the values. The Vault policy must allow read/write to this path for authenticated users. + +### Required Vault policy + +In addition to the existing address book policy, add: + +```hcl +# User credential variables (read/write own credentials) +path "secret/data/rustguac/users/*" { + capabilities = ["create", "read", "update", "delete"] +} +path "secret/metadata/rustguac/users/*" { + capabilities = ["list", "read", "delete"] +} +``` + +## API endpoints + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| `GET` | `/api/me/credentials` | operator+ | List own saved variables (passwords masked) | +| `PUT` | `/api/me/credentials` | operator+ | Save/update own variables | +| `GET` | `/api/credential-variables` | operator+ | List all variables used across accessible entries | diff --git a/install.sh b/install.sh index 35e216c..42c15d7 100755 --- a/install.sh +++ b/install.sh @@ -81,7 +81,7 @@ install_deps() { # Xvnc and Chromium for web browser sessions apt-get install -y \ tigervnc-standalone-server \ - chromium \ + chromium chromium-sandbox \ x11-utils # Runtime utilities @@ -187,6 +187,25 @@ build_guacd() { info "Installing guacd to $PREFIX..." make install + # Verify FreeRDP plugins were installed (required for drive redirection + audio) + local freerdp_plugin_dir + freerdp_plugin_dir=$(pkg-config --variable=libdir freerdp3 2>/dev/null || pkg-config --variable=libdir freerdp2 2>/dev/null)/freerdp3 + if [[ -d "$freerdp_plugin_dir" ]]; then + local plugin_count + plugin_count=$(find "$freerdp_plugin_dir" -name "libguac*" 2>/dev/null | wc -l) + if [[ "$plugin_count" -gt 0 ]]; then + info "FreeRDP plugins installed to $freerdp_plugin_dir ($plugin_count plugins)" + else + warn "FreeRDP plugins NOT found in $freerdp_plugin_dir — drive redirection will not work" + # Try to copy from the build + if [[ -d "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs" ]]; then + info "Copying FreeRDP plugins manually..." + cp -a "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs"/libguac-common-svc-client*.so* "$freerdp_plugin_dir/" 2>/dev/null || true + cp -a "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs"/libguacai-client*.so* "$freerdp_plugin_dir/" 2>/dev/null || true + fi + fi + fi + info "guacd installed: $PREFIX/sbin/guacd" } diff --git a/src/api.rs b/src/api.rs index bdbaca9..ac67bd8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -229,10 +229,12 @@ pub async fn auth_status( Extension(oidc_enabled): Extension, Extension(site_title): Extension, Extension(theme): Extension, + Extension(drive_configured): Extension, ) -> impl IntoResponse { let mut resp = json!({ "oidc_enabled": oidc_enabled.0, "site_title": site_title.0, + "drive_configured": drive_configured.0, }); resp["theme"] = json!({ "admin_preset": theme.admin_preset, @@ -271,6 +273,10 @@ pub struct OidcEnabled(pub bool); #[derive(Clone)] pub struct VaultConfigured(pub bool); +/// Marker for whether [drive] is configured. +#[derive(Clone)] +pub struct DriveConfigured(pub bool); + /// GET /api/recordings — List all recording files. All authenticated roles. pub async fn list_recordings(State(manager): State) -> impl IntoResponse { let recording_path = manager.recording_path().to_path_buf(); @@ -1217,6 +1223,41 @@ pub async fn ab_connect_entry( } }; + // Resolve credential variable references ($domain_user, $domain_password, etc.) + let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() { + let user_email = match &id { + AuthIdentity::User { email, .. } => Some(email.clone()), + _ => None, + }; + if let Some(email) = user_email { + match vault.get_user_credentials(&email).await { + Ok(user_creds) => { + match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) { + Ok(resolved) => resolved, + Err(missing) => { + return ( + StatusCode::PRECONDITION_FAILED, + Json(json!({ + "error": "missing credential variables", + "missing_variables": missing, + })), + ) + .into_response() + } + } + } + Err(e) => { + tracing::warn!("Failed to read user credentials from Vault: {}", e); + ab_entry // Fall through with unresolved variables + } + } + } else { + ab_entry // API key users — no variable resolution + } + } else { + ab_entry + }; + // Map address book entry type to SessionType let session_type = match ab_entry.session_type.as_str() { "ssh" => SessionType::Ssh, @@ -1901,6 +1942,241 @@ pub async fn revoke_my_token( } } +// ── User credential variables ── + +/// GET /api/me/credentials — List own credential variables (names and masked values). +pub async fn get_my_credentials( + identity: Option>, + Extension(vault): Extension, +) -> impl IntoResponse { + let email = match identity { + Some(Extension(AuthIdentity::User { ref email, .. })) => email.clone(), + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "OIDC authentication required"})), + ) + .into_response() + } + }; + + let vault = match require_vault(&vault).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + match vault.get_user_credentials(&email).await { + Ok(creds) => { + // Return variable names with masked values (indicate set vs unset) + let masked: serde_json::Map = creds + .iter() + .map(|(k, v)| { + let display = if v.is_empty() { + "".to_string() + } else if k.ends_with("_password") || k.ends_with("_key") { + "••••••••".to_string() + } else { + v.clone() + }; + ( + k.clone(), + json!({ "set": !v.is_empty(), "display": display }), + ) + }) + .collect(); + Json(json!({ "credentials": masked })).into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("Failed to read credentials: {}", e)})), + ) + .into_response(), + } +} + +/// PUT /api/me/credentials — Save credential variables. Operator+ required. +pub async fn put_my_credentials( + identity: Option>, + Extension(vault): Extension, + Json(body): Json, +) -> impl IntoResponse { + let email = match identity { + Some(Extension(AuthIdentity::User { + ref email, + ref role, + .. + })) if role_level(role) >= role_level("operator") => email.clone(), + Some(Extension(AuthIdentity::User { .. })) => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "operator role required"})), + ) + .into_response() + } + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "OIDC authentication required"})), + ) + .into_response() + } + }; + + let vault = match require_vault(&vault).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + // Expect { "credentials": { "corp_user": "alice", "corp_password": "secret", ... } } + let creds_val = match body.get("credentials") { + Some(v) => v, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "missing 'credentials' field"})), + ) + .into_response() + } + }; + + let creds_obj = match creds_val.as_object() { + Some(obj) => obj, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "'credentials' must be an object"})), + ) + .into_response() + } + }; + + // Validate variable names: [a-z0-9_-]+ + let mut creds = std::collections::HashMap::new(); + for (k, v) in creds_obj { + if !k + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("invalid variable name '{}': use lowercase alphanumeric, underscores and hyphens", k)})), + ) + .into_response(); + } + let val = v.as_str().unwrap_or("").to_string(); + creds.insert(k.clone(), val); + } + + // Merge with existing credentials (so partial updates work) + match vault.get_user_credentials(&email).await { + Ok(mut existing) => { + for (k, v) in &creds { + if v.is_empty() { + existing.remove(k); // Empty value = delete + } else { + existing.insert(k.clone(), v.clone()); + } + } + creds = existing; + } + Err(VaultError::NotFound) => {} // No existing, use new + Err(e) => { + tracing::warn!("Failed to read existing credentials: {}", e); + } + } + + match vault.put_user_credentials(&email, &creds).await { + Ok(()) => { + tracing::info!(user = %email, count = creds.len(), "User credentials updated"); + Json(json!({"ok": true, "count": creds.len()})).into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("Failed to save credentials: {}", e)})), + ) + .into_response(), + } +} + +/// GET /api/credential-variables — List all unique credential variables across address book entries. +/// Returns grouped by domain prefix, with entry counts. +pub async fn list_credential_variables( + identity: Option>, + Extension(vault): Extension, +) -> impl IntoResponse { + let id = match identity { + Some(Extension(ref id)) if id.has_role("operator") => id.clone(), + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "operator role required"})), + ) + .into_response() + } + }; + + let vault = match require_vault(&vault).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + // Scan all accessible folders and entries for variable references + let folders = match vault.list_folders().await { + Ok(f) => f, + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("Failed to list folders: {}", e)})), + ) + .into_response() + } + }; + + let mut all_vars: std::collections::HashMap = std::collections::HashMap::new(); + + for folder in &folders { + // Check folder access for this user + if check_folder_access(&vault, &folder.scope, &folder.name, &id) + .await + .is_err() + { + continue; + } + + let entries = match vault.list_entries(&folder.scope, &folder.name).await { + Ok(e) => e, + Err(_) => continue, + }; + + for entry_name in &entries { + if let Ok(entry) = vault + .get_entry(&folder.scope, &folder.name, entry_name) + .await + { + for var in crate::vault::entry_credential_variables(&entry) { + *all_vars.entry(var).or_insert(0) += 1; + } + } + } + } + + // Group by domain prefix (everything before the last _user/_password/_domain/_key suffix) + let mut domains: std::collections::HashMap> = + std::collections::HashMap::new(); + for (var, count) in &all_vars { + let domain = var + .rsplit_once('_') + .map(|(prefix, _suffix)| prefix.to_string()) + .unwrap_or_else(|| var.clone()); + domains + .entry(domain) + .or_default() + .push(json!({"name": var, "entry_count": count})); + } + + Json(json!({ "variables": all_vars, "domains": domains })).into_response() +} + /// POST /api/admin/user-tokens — Admin creates a token for any user. pub async fn admin_create_user_token( identity: Option>, @@ -2301,6 +2577,40 @@ pub async fn quick_connect( } }; + // Resolve credential variable references + let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() { + let user_email = match &id { + AuthIdentity::User { email, .. } => Some(email.clone()), + _ => None, + }; + if let Some(email) = user_email { + match vault.get_user_credentials(&email).await { + Ok(user_creds) => { + match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) { + Ok(resolved) => resolved, + Err(missing) => { + return quick_connect_error( + StatusCode::PRECONDITION_FAILED, + &format!( + "Missing credential variables: {}. Set them in My Credentials.", + missing.join(", ") + ), + ); + } + } + } + Err(e) => { + tracing::warn!("Failed to read user credentials from Vault: {}", e); + ab_entry + } + } + } else { + ab_entry + } + } else { + ab_entry + }; + // Check if we need to prompt for credentials before connecting let needs_prompt = ab_entry.session_type != "web" && (ab_entry.prompt_credentials == Some(true) diff --git a/src/config.rs b/src/config.rs index b3821c4..66d3901 100644 --- a/src/config.rs +++ b/src/config.rs @@ -252,6 +252,12 @@ pub struct Config { #[serde(default = "default_localhost_networks")] pub web_allowed_networks: Vec, + /// Enable API rate limiting. Default: false. + /// When behind a reverse proxy (HAProxy, nginx) or access gateway (KnockNoc), + /// rate limiting is typically handled upstream and not needed here. + #[serde(default)] + pub rate_limit: bool, + /// Trusted proxy CIDRs. When the connecting IP matches one of these, /// the first address in X-Forwarded-For is used as the real client IP. #[serde(default)] @@ -805,6 +811,7 @@ impl Default for Config { rdp_allowed_networks: default_localhost_networks(), vnc_allowed_networks: default_localhost_networks(), web_allowed_networks: default_localhost_networks(), + rate_limit: false, trusted_proxies: Vec::new(), tls: None, oidc: None, diff --git a/src/main.rs b/src/main.rs index a179bc5..cc056cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,9 @@ mod tunnel; mod vault; mod websocket; -use crate::api::{AppState, OidcEnabled, SiteTitle, ThemeData, VaultConfigured, VaultState}; +use crate::api::{ + AppState, DriveConfigured, OidcEnabled, SiteTitle, ThemeData, VaultConfigured, VaultState, +}; use crate::config::Config; use crate::db::Db; use crate::session::SessionManager; @@ -583,6 +585,7 @@ async fn run_server(config: Config, database: Db) { let oidc_enabled = OidcEnabled(oidc_state.is_some()); let vault_configured = VaultConfigured(config.vault.is_some()); + let drive_configured = DriveConfigured(config.drive.is_some()); let site_title = SiteTitle(config.site_title.clone()); let theme_data = { let (admin_preset, admin_colors) = config @@ -671,6 +674,7 @@ async fn run_server(config: Config, database: Db) { // Build TLS connector for guacd if configured let guacd_tls = build_guacd_tls(&config); + let rate_limit_enabled = config.rate_limit; // Create session manager let manager: AppState = Arc::new(SessionManager::new(config, guacd_tls)); @@ -717,36 +721,27 @@ async fn run_server(config: Config, database: Db) { } } - // Rate limit configs - let api_governor_conf = GovernorConfigBuilder::default() - .per_second(20) - .burst_size(100) - .key_extractor(SmartIpKeyExtractor) - .finish() - .expect("Failed to build API rate limit config"); + // Rate limiting (disabled by default — handle upstream in reverse proxy) + if rate_limit_enabled { + tracing::info!("API rate limiting enabled"); + } - let session_create_governor_conf = GovernorConfigBuilder::default() - .per_second(2) - .burst_size(10) - .key_extractor(SmartIpKeyExtractor) - .finish() - .expect("Failed to build session creation rate limit config"); - - let ws_governor_conf = GovernorConfigBuilder::default() - .per_second(5) - .burst_size(50) - .key_extractor(SmartIpKeyExtractor) - .finish() - .expect("Failed to build WebSocket rate limit config"); - - // Session creation route with extra rate limit layer - let session_create_route = Router::new() + // Session creation route (rate-limited only when enabled) + let mut session_create_route = Router::new() .route("/api/sessions", post(api::create_session)) - .with_state(manager.clone()) - .layer(GovernorLayer::new(session_create_governor_conf)); + .with_state(manager.clone()); + if rate_limit_enabled { + let conf = GovernorConfigBuilder::default() + .per_second(2) + .burst_size(10) + .key_extractor(SmartIpKeyExtractor) + .finish() + .expect("Failed to build session creation rate limit config"); + session_create_route = session_create_route.layer(GovernorLayer::new(conf)); + } // API routes that require authentication - let api_routes = Router::new() + let mut api_routes = Router::new() .route("/api/sessions", get(api::list_sessions)) .route("/api/sessions/{id}", get(api::get_session)) .route("/api/sessions/{id}", delete(api::delete_session)) @@ -777,6 +772,13 @@ async fn run_server(config: Config, database: Db) { .route("/api/me/tokens", get(api::list_my_tokens)) .route("/api/me/tokens", post(api::create_my_token)) .route("/api/me/tokens/{id}", delete(api::revoke_my_token)) + // User credential variables + .route("/api/me/credentials", get(api::get_my_credentials)) + .route("/api/me/credentials", put(api::put_my_credentials)) + .route( + "/api/credential-variables", + get(api::list_credential_variables), + ) // Admin token management .route("/api/admin/user-tokens", get(api::admin_list_user_tokens)) .route("/api/admin/user-tokens", post(api::admin_create_user_token)) @@ -820,18 +822,36 @@ async fn run_server(config: Config, database: Db) { post(api::ab_connect_entry), ) .merge(session_create_route) - .with_state(manager.clone()) - .layer(GovernorLayer::new(api_governor_conf)) + .with_state(manager.clone()); + if rate_limit_enabled { + let conf = GovernorConfigBuilder::default() + .per_second(20) + .burst_size(100) + .key_extractor(SmartIpKeyExtractor) + .finish() + .expect("Failed to build API rate limit config"); + api_routes = api_routes.layer(GovernorLayer::new(conf)); + } + let api_routes = api_routes .layer(middleware::from_fn(auth::require_auth)) .layer(Extension(vault_client.clone())) .layer(Extension(vault_configured.clone())) .layer(Extension(database.clone())); - // WebSocket route with optional auth and rate limiting - let ws_route = Router::new() + // WebSocket route with optional auth + let mut ws_route = Router::new() .route("/ws/{session_id}", get(websocket::ws_handler)) - .with_state(manager.clone()) - .layer(GovernorLayer::new(ws_governor_conf)) + .with_state(manager.clone()); + if rate_limit_enabled { + let conf = GovernorConfigBuilder::default() + .per_second(5) + .burst_size(50) + .key_extractor(SmartIpKeyExtractor) + .finish() + .expect("Failed to build WebSocket rate limit config"); + ws_route = ws_route.layer(GovernorLayer::new(conf)); + } + let ws_route = ws_route .layer(middleware::from_fn(auth::optional_auth)) .layer(Extension(database.clone())); @@ -905,6 +925,7 @@ async fn run_server(config: Config, database: Db) { .layer(middleware::from_fn(security_headers)) .layer(Extension(tls_enabled)) .layer(Extension(oidc_enabled)) + .layer(Extension(drive_configured)) .layer(Extension(site_title)) .layer(Extension(theme_data)) .layer(Extension(trusted_proxies)) diff --git a/src/session.rs b/src/session.rs index f80920e..95568e0 100644 --- a/src/session.rs +++ b/src/session.rs @@ -418,6 +418,14 @@ impl SessionManager { let drive_enabled = drive::is_drive_enabled(&self.config.drive, req.enable_drive); let drive_cfg = drive::drive_config_or_default(&self.config.drive); + tracing::info!( + %session_id, + drive_enabled, + entry_enable_drive = ?req.enable_drive, + has_drive_config = self.config.drive.is_some(), + drive_path = ?drive_cfg.drive_path, + "Drive configuration" + ); // Create per-session drive directory for RDP let session_drive_path = if drive_enabled { @@ -440,6 +448,7 @@ impl SessionManager { ignore_cert = rdp_ignore_cert, security = ?rdp_security, enable_drive = rdp_enable_drive, + drive_path = ?session_drive_path, domain = ?req.domain, has_password = req.password.is_some(), "RDP session params" diff --git a/src/vault.rs b/src/vault.rs index dd6c8c1..8783611 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -9,6 +9,7 @@ //! (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; @@ -57,7 +58,7 @@ pub struct FolderConfig { } /// A connection entry stored in Vault. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AddressBookEntry { #[serde(rename = "type")] pub session_type: String, // "ssh", "rdp", "vnc", "web" @@ -226,6 +227,9 @@ pub struct EntryInfo { /// Banner text shown before session starts. #[serde(skip_serializing_if = "Option::is_none")] pub banner: Option, + /// Credential variable names referenced by this entry (e.g. ["corp_user", "corp_password"]). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub credential_variables: Vec, } impl From<(&str, &AddressBookEntry)> for EntryInfo { @@ -269,6 +273,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { disable_copy: e.disable_copy, disable_paste: e.disable_paste, banner: e.banner.clone(), + credential_variables: entry_credential_variables(e), } } } @@ -756,6 +761,89 @@ impl VaultClient { s => Err(VaultError::Parse(format!("list failed ({})", s))), } } + + // ── User credential variables ── + + /// Read a user's stored credential variables from Vault. + /// Path: `/users/` + pub async fn get_user_credentials( + &self, + email: &str, + ) -> Result, 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: `/users/` + pub async fn put_user_credentials( + &self, + email: &str, + creds: &HashMap, + ) -> 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). @@ -960,6 +1048,88 @@ fn validate_name(name: &str) -> Result<(), VaultError> { 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 { + [ + &entry.username, + &entry.password, + &entry.domain, + &entry.private_key, + ] + .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, +) -> Result> { + let mut resolved = entry.clone(); + let mut missing = Vec::new(); + + fn resolve_field( + field: &mut Option, + creds: &HashMap, + missing: &mut Vec, + ) { + 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); + + if missing.is_empty() { + Ok(resolved) + } else { + Err(missing) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1353,4 +1523,78 @@ mod tests { 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")); + } + + #[test] + fn test_entry_credential_variables() { + let mut entry = AddressBookEntry::default(); + entry.username = Some("$corp_user".into()); + entry.password = Some("$corp_password".into()); + entry.domain = Some("CORP".into()); // literal, not a variable + let vars = entry_credential_variables(&entry); + assert_eq!(vars, vec!["corp_user", "corp_password"]); + } + + #[test] + fn test_resolve_credential_variables_success() { + let mut entry = AddressBookEntry::default(); + entry.username = Some("$corp_user".into()); + entry.password = Some("$corp_password".into()); + entry.domain = Some("CORP".into()); + entry.hostname = Some("rdp.example.com".into()); + + 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 mut entry = AddressBookEntry::default(); + entry.username = Some("$corp_user".into()); + entry.password = Some("$corp_password".into()); + + 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 mut entry = AddressBookEntry::default(); + entry.username = Some("alice".into()); + entry.password = Some("literal_pass".into()); + + 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")); + } } diff --git a/src/websocket.rs b/src/websocket.rs index 5dab038..3430d30 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -316,6 +316,15 @@ async fn guacd_to_ws( // Forward to browser via WebSocket let text = String::from_utf8_lossy(data).into_owned(); + + // Log filesystem and clipboard instructions from guacd + if text.contains(".filesystem,") { + tracing::info!("guacd sent filesystem instruction"); + } + if text.contains(".clipboard,") { + tracing::info!("guacd sent clipboard instruction to browser"); + } + ws.send(Message::Text(text.into())).await?; } @@ -331,6 +340,10 @@ async fn ws_to_guacd( let msg = msg?; match msg { Message::Text(text) => { + // Log clipboard instructions from browser → guacd + if text.contains(".clipboard,") { + tracing::info!("browser sent clipboard instruction to guacd"); + } guacd.write_all(text.as_bytes()).await?; } Message::Binary(data) => { diff --git a/static/addressbook.html b/static/addressbook.html index 9447d38..8a72e59 100644 --- a/static/addressbook.html +++ b/static/addressbook.html @@ -8,7 +8,7 @@ body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; } h1 { color: var(--primary); } a { color: var(--accent); } - nav { margin-bottom: 1.5em; font-size: 0.95em; } + nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; } nav a { margin-right: 1.5em; text-decoration: none; } nav a:hover { text-decoration: underline; } nav .active { color: var(--primary); font-weight: bold; } @@ -155,6 +155,10 @@ .modal-actions .btn-cancel:hover { background: #444; } .field-hint { font-size: 0.8em; color: var(--text-dim); margin-top: 0.2em; } + .pw-wrap { position: relative; } + .pw-wrap input { padding-right: 2.2em; } + .pw-toggle { position: absolute; right: 0.4em; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.85em; padding: 0.2em; line-height: 1; width: auto; } + .pw-toggle:hover { color: var(--text); } /* Hop cards */ .hop-card { @@ -254,7 +258,7 @@ Docs -
Logout
+
🔑 My Credentials
Logout
@@ -362,7 +366,7 @@ + +