v0.8.0: Credential variables, bug fixes

Credential variables — address book entries reference $domain_username /
$domain_password instead of storing static credentials. Users fill in their
own values via My Credentials (gear menu), stored per-user in Vault KV.
All variables set → silent launch; missing → prompted. Hyphens allowed in
variable names. Docs section added.

Bug fixes:
- Rate limiting disabled by default; opt-in via rate_limit = true (#62)
- Docker: copy FreeRDP guac-common-svc plugins to runtime image (#64)
- Docker/install: add chromium-sandbox package for non-root web sessions (#61)
- Logo: skip redundant JS src= when server-side branding already set (#65)
- Sessions page: hide Open/Share buttons for non-active sessions (#63)
- Drive: expose drive_configured in /api/auth/status, warn in UI when
  [drive] not configured
- install.sh: verify FreeRDP plugin installation

UI polish:
- Nav bar: border separator + spacing between header and nav on all pages
- Address book: password show/hide toggle on all password fields
- Drive diagnostic logging (session.rs, websocket.rs, client.html)

Closes #61, #62, #63, #64, #65
This commit is contained in:
Dave Kempe
2026-03-13 11:43:42 +11:00
parent 6b1ad57fdc
commit 6bafc79d60
20 changed files with 1027 additions and 65 deletions
Generated
+1 -1
View File
@@ -3015,7 +3015,7 @@ dependencies = [
[[package]]
name = "rustguac"
version = "0.7.2"
version = "0.8.0"
dependencies = [
"aes",
"axum",
+1 -1
View File
@@ -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"
+16 -3
View File
@@ -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=$!
+1
View File
@@ -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",
+85
View File
@@ -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 `$<domain>_<suffix>`:
| Pattern | Purpose | Input type |
|---------|---------|------------|
| `$<domain>_username` | Username | Text |
| `$<domain>_password` | Password | Password (masked) |
| `$<domain>_domain` | AD/Windows domain | Text |
| `$<domain>_key` | SSH private key | Textarea |
The `<domain>` 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:
```
<base_path>/users/<sanitized_email>
```
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 |
+20 -1
View File
@@ -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"
}
+310
View File
@@ -229,10 +229,12 @@ pub async fn auth_status(
Extension(oidc_enabled): Extension<OidcEnabled>,
Extension(site_title): Extension<SiteTitle>,
Extension(theme): Extension<ThemeData>,
Extension(drive_configured): Extension<DriveConfigured>,
) -> 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<AppState>) -> 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<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> 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<String, serde_json::Value> = 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<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
Json(body): Json<serde_json::Value>,
) -> 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<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> 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<String, u32> = 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<String, Vec<serde_json::Value>> =
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<Extension<AuthIdentity>>,
@@ -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)
+7
View File
@@ -252,6 +252,12 @@ pub struct Config {
#[serde(default = "default_localhost_networks")]
pub web_allowed_networks: Vec<String>,
/// 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,
+54 -33
View File
@@ -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))
+9
View File
@@ -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"
+245 -1
View File
@@ -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<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>,
}
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: `<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).
@@ -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<String> {
[
&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<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);
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"));
}
}
+13
View File
@@ -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) => {
+201 -9
View File
@@ -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 @@
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
<span id="user-menu-wrapper"><span id="user-menu-btn" title="Preferences">&#9881;</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div><div class="um-divider"></div><div class="um-item um-logout" id="logout-item">Logout</div></div></span>
<span id="user-menu-wrapper"><span id="user-menu-btn" title="Preferences">&#9881;</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div><div class="um-divider"></div><div class="um-item" id="my-creds-item">&#128273; My Credentials</div><div class="um-divider"></div><div class="um-item um-logout" id="logout-item">Logout</div></div></span>
</nav>
<div id="global-error"></div>
@@ -362,7 +366,7 @@
<input type="text" id="em-username">
</label>
<label>Password
<input type="password" id="em-password">
<div class="pw-wrap"><input type="password" id="em-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">&#9679;</button></div>
</label>
<label>Private key (PEM)
<textarea id="em-private-key" rows="3" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
@@ -383,7 +387,7 @@
<input type="text" id="em-rdp-username">
</label>
<label>Password
<input type="password" id="em-rdp-password">
<div class="pw-wrap"><input type="password" id="em-rdp-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">&#9679;</button></div>
</label>
<label>Domain
<input type="text" id="em-rdp-domain">
@@ -425,7 +429,7 @@
<input type="number" id="em-vnc-port" value="5900">
</label>
<label>Password
<input type="password" id="em-vnc-password">
<div class="pw-wrap"><input type="password" id="em-vnc-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">&#9679;</button></div>
</label>
<label>Color depth
<select id="em-vnc-color-depth">
@@ -459,7 +463,7 @@
<input type="text" id="em-web-username" placeholder="For URL substitution or login script">
</label>
<label>Password <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
<input type="password" id="em-web-password">
<div class="pw-wrap"><input type="password" id="em-web-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">&#9679;</button></div>
</label>
<label>Login Script <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
<select id="em-login-script">
@@ -512,6 +516,7 @@
<label>
<input type="checkbox" id="em-enable-drive" style="display:inline;width:auto;margin-right:0.4em"> Enable file transfer
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">RDP: mounts a shared drive. SSH: enables SFTP file browser.</div>
<div id="em-drive-warning" style="display:none;color:var(--primary);font-size:0.8em;margin-top:0.3em">Server has no [drive] section in config — file transfer will not work. See <a href="/docs.html" style="color:var(--accent)">Docs</a> for setup instructions.</div>
</label>
</div>
<div id="em-recording-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
@@ -571,6 +576,21 @@
</div>
<!-- Credential prompt modal -->
<div class="modal-overlay" id="my-creds-modal">
<div class="modal" style="max-width:500px">
<h3>My Credentials</h3>
<p style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Address book entries that use credential variables will auto-fill from these values.</p>
<div id="my-creds-loading" style="color:var(--text-muted)">Loading...</div>
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in address book entries yet.</div>
<div id="my-creds-list"></div>
<div id="my-creds-error" style="color:var(--primary);margin-top:0.5em"></div>
<div class="modal-actions">
<button class="btn-connect" id="my-creds-save">Save</button>
<button class="btn-cancel" id="my-creds-close">Close</button>
</div>
</div>
</div>
<div class="modal-overlay" id="cred-modal">
<div class="modal">
<h3 id="cred-modal-title">Enter Credentials</h3>
@@ -597,13 +617,16 @@
</div>
<script>
function togglePw(btn){var inp=btn.previousElementSibling;if(inp.type==='password'){inp.type='text';btn.textContent='\u25CB'}else{inp.type='password';btn.textContent='\u25CF'}}
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
var _driveConfigured = false;
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Address Book';document.querySelector('h1').textContent=d.site_title;}
_driveConfigured = !!d.drive_configured;
initTheme(d.theme);
});
var apiKey = sessionStorage.getItem('rustguac_api_key');
@@ -956,14 +979,28 @@
body: JSON.stringify(body)
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
if (!res.ok) {
if (res.status === 412) {
return res.json().then(function(j) {
var err = new Error('missing_credentials');
err.missing = j.missing_variables || [];
throw err;
});
}
return res.text().then(function(t) { throw new Error(t); });
}
return res.json();
})
.then(function(data) {
window.open(data.client_url, '_blank');
})
.catch(function(err) {
showError(err.message);
if (err.message === 'missing_credentials') {
showError('Missing credentials: ' + (err.missing || []).join(', ') + '. Set them in My Credentials (⚙ menu).');
openMyCredentials();
} else {
showError(err.message);
}
})
.finally(function() {
btn.disabled = false;
@@ -1291,6 +1328,10 @@
});
// Toggle Autofill fields visibility
document.getElementById('em-enable-drive').addEventListener('change', function() {
var warn = document.getElementById('em-drive-warning');
warn.style.display = (this.checked && !_driveConfigured) ? '' : 'none';
});
document.getElementById('em-automation-toggle').addEventListener('click', function() {
var fields = document.getElementById('em-automation-fields');
var arrow = document.getElementById('em-automation-arrow');
@@ -1604,6 +1645,7 @@
document.getElementById('em-disable-copy').checked = false;
document.getElementById('em-disable-paste').checked = false;
document.getElementById('em-enable-drive').checked = false;
document.getElementById('em-drive-warning').style.display = 'none';
document.getElementById('em-remote-app').value = '';
document.getElementById('em-remote-app-dir').value = '';
document.getElementById('em-remote-app-args').value = '';
@@ -1718,6 +1760,7 @@
}
if (type === 'ssh' || type === 'rdp') {
document.getElementById('em-enable-drive').checked = !!entryData.enable_drive;
document.getElementById('em-drive-warning').style.display = (entryData.enable_drive && !_driveConfigured) ? '' : 'none';
}
// Populate RemoteApp fields (RDP)
if (type === 'rdp' && (entryData.remote_app || entryData.remote_app_dir || entryData.remote_app_args)) {
@@ -1983,6 +2026,155 @@
}
});
// ── My Credentials ──
document.getElementById('my-creds-item').addEventListener('click', function() {
document.getElementById('user-menu').style.display = 'none';
openMyCredentials();
});
document.getElementById('my-creds-close').addEventListener('click', function() {
document.getElementById('my-creds-modal').classList.remove('active');
});
document.getElementById('my-creds-save').addEventListener('click', saveMyCredentials);
function openMyCredentials() {
var modal = document.getElementById('my-creds-modal');
var list = document.getElementById('my-creds-list');
var loading = document.getElementById('my-creds-loading');
var empty = document.getElementById('my-creds-empty');
var errEl = document.getElementById('my-creds-error');
list.innerHTML = '';
loading.style.display = '';
empty.style.display = 'none';
errEl.textContent = '';
modal.classList.add('active');
// Fetch required variables and user's current values in parallel
Promise.all([
fetch('/api/credential-variables', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); }),
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); })
]).then(function(results) {
loading.style.display = 'none';
var varsData = results[0];
var credsData = results[1];
var allVars = varsData.variables || {};
var domains = varsData.domains || {};
var saved = credsData.credentials || {};
var varNames = Object.keys(allVars).sort();
if (varNames.length === 0) {
empty.style.display = '';
return;
}
// Group by domain prefix
var domainKeys = Object.keys(domains).sort();
domainKeys.forEach(function(domain) {
var heading = document.createElement('div');
heading.style.cssText = 'color:var(--accent);font-weight:bold;margin-top:1em;margin-bottom:0.3em;font-size:0.95em;text-transform:uppercase';
heading.textContent = domain;
list.appendChild(heading);
var vars = domains[domain].sort(function(a, b) {
// username before password before key before other
function suffixOrder(n) {
if (n.endsWith('_username')) return 0;
if (n.endsWith('_password')) return 1;
if (n.endsWith('_domain')) return 2;
if (n.endsWith('_key')) return 3;
return 4;
}
var oa = suffixOrder(a.name), ob = suffixOrder(b.name);
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
});
vars.forEach(function(v) {
var name = v.name;
var isSecret = name.endsWith('_password') || name.endsWith('_key');
var row = document.createElement('label');
row.style.cssText = 'display:block;margin-top:0.5em;color:var(--text-muted);font-size:0.9em';
var displayName = name.replace(domain + '_', '');
row.textContent = displayName;
var hint = document.createElement('span');
hint.style.cssText = 'color:var(--text-dim);font-size:0.8em;margin-left:0.5em';
hint.textContent = '(' + v.entry_count + ' ' + (v.entry_count === 1 ? 'entry' : 'entries') + ')';
row.appendChild(hint);
var input;
if (name.endsWith('_key')) {
input = document.createElement('textarea');
input.rows = 3;
input.placeholder = 'Paste SSH private key';
} else {
input = document.createElement('input');
input.type = isSecret ? 'password' : 'text';
input.placeholder = isSecret ? '••••••••' : '';
}
input.setAttribute('data-var', name);
input.className = 'my-cred-input';
// Pre-fill with saved value (non-secret) or leave blank (secret shows placeholder)
var sv = saved[name];
if (sv) {
if (isSecret) {
input.placeholder = '(saved — leave blank to keep)';
} else {
input.value = sv.display || '';
}
}
row.appendChild(input);
list.appendChild(row);
});
});
}).catch(function(err) {
loading.style.display = 'none';
errEl.textContent = 'Failed to load: ' + err.message;
});
}
function saveMyCredentials() {
var inputs = document.querySelectorAll('.my-cred-input');
var creds = {};
var hasChanges = false;
inputs.forEach(function(el) {
var name = el.getAttribute('data-var');
var val = el.value.trim();
if (val) {
creds[name] = val;
hasChanges = true;
}
});
if (!hasChanges) {
document.getElementById('my-creds-modal').classList.remove('active');
return;
}
var errEl = document.getElementById('my-creds-error');
var btn = document.getElementById('my-creds-save');
btn.disabled = true;
btn.textContent = 'Saving...';
errEl.textContent = '';
fetch('/api/me/credentials', {
method: 'PUT',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify({ credentials: creds })
})
.then(function(res) {
if (!res.ok) return res.json().then(function(j) { throw new Error(j.error || 'Save failed'); });
return res.json();
})
.then(function() {
document.getElementById('my-creds-modal').classList.remove('active');
})
.catch(function(err) {
errEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Save';
});
}
init();
</script>
</body>
+2 -2
View File
@@ -9,7 +9,7 @@
h1 { color: var(--primary); }
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
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; }
@@ -172,7 +172,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Admin';document.querySelector('h1').textContent=d.site_title;}
+50 -3
View File
@@ -202,6 +202,18 @@
var tunnel = new Guacamole.WebSocketTunnel(wsUrl);
var client = new Guacamole.Client(tunnel);
// Wrap tunnel.oninstruction to log unique opcodes from guacd (diagnostic)
var seenOpcodes = {};
var origOnInstruction = tunnel.oninstruction;
tunnel.oninstruction = function(opcode, args) {
if (!seenOpcodes[opcode]) {
seenOpcodes[opcode] = true;
if (typeof console !== 'undefined') console.log('[rustguac] instruction: ' + opcode + (args.length ? ' (' + args.length + ' args)' : ''));
}
if (origOnInstruction) origOnInstruction(opcode, args);
};
var displayEl = document.getElementById('display');
displayEl.appendChild(client.getDisplay().getElement());
@@ -343,11 +355,14 @@
scaleDisplay();
}
// ── Auto-sync clipboard on window focus (Chrome only, silent fail) ──
// ── Auto-sync clipboard on window focus ──
window.addEventListener('focus', function() {
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText && !/Firefox/i.test(navigator.userAgent)) {
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && text !== remoteClipboard) sendClipboardToRemote(text);
if (text && text !== remoteClipboard) {
sendClipboardToRemote(text);
remoteClipboard = text;
}
}).catch(function() {});
}
});
@@ -738,6 +753,38 @@
}
}, true);
// ── Ctrl+V clipboard sync ──
// Intercept Ctrl+V (capture on document, fires before Guacamole's
// handler on displayEl). Reads browser clipboard, syncs to remote,
// then sends key events. Firefox shows a one-time permission popup.
var _pasteIntercepted = false;
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !e.altKey && !e.repeat && !panelOpen) {
if (navigator.clipboard && navigator.clipboard.readText) {
e.stopPropagation();
e.preventDefault();
_pasteIntercepted = true;
navigator.clipboard.readText().then(function(text) {
if (text && text !== remoteClipboard) {
sendClipboardToRemote(text);
remoteClipboard = text;
}
}).catch(function() {}).finally(function() {
// Send Ctrl+V key events to remote after clipboard sync
client.sendKeyEvent(1, 0x76); // v down
client.sendKeyEvent(0, 0x76); // v up
});
}
}
}, true);
document.addEventListener('keyup', function(e) {
if (_pasteIntercepted && (e.key === 'v' || e.key === 'V')) {
e.stopPropagation();
e.preventDefault();
_pasteIntercepted = false;
}
}, true);
keyboard.onkeydown = function(keysym) {
client.sendKeyEvent(1, keysym);
};
+2 -2
View File
@@ -7,7 +7,7 @@
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #666; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1b4332; --type-ssh-fg: #52b788; --type-rdp-bg: #3d1f00; --type-rdp-fg: #f0a050; --type-vnc-bg: #2d1b4e; --type-vnc-fg: #b07ff0; --type-web-bg: #1a1a4e; --type-web-fg: #7b8ff0; --hop-bg: #1b4332; --hop-fg: #52b788; --bg-pattern: none; }
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); }
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; color: var(--accent); }
nav a:hover { text-decoration: underline; }
nav .active { color: var(--primary); font-weight: bold; }
@@ -199,7 +199,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Docs';document.querySelector('h1').textContent=d.site_title;}
+1 -1
View File
@@ -125,7 +125,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}}
var loginForm = document.getElementById('login-form');
var apiKeyToggle = document.getElementById('api-key-toggle');
+2 -2
View File
@@ -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; }
@@ -194,7 +194,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Recordings';document.querySelector('h1').textContent=d.site_title;}
+5 -4
View File
@@ -10,7 +10,7 @@
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
a { color: var(--accent); }
code { background: var(--surface); padding: 0.2em 0.5em; border-radius: 3px; }
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; }
@@ -320,7 +320,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
@@ -708,12 +708,13 @@
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
tr.appendChild(h('td', String(s.active_connections)));
var isActive = s.status === 'active';
var tdOpen = document.createElement('td');
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
if (isActive) tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
tr.appendChild(tdOpen);
var tdShare = document.createElement('td');
tdShare.appendChild(h('button', 'share', { 'class': 'btn-small btn-share', 'data-url': shareFullUrl, 'data-row': rowId }));
if (isActive) tdShare.appendChild(h('button', 'share', { 'class': 'btn-small btn-share', 'data-url': shareFullUrl, 'data-row': rowId }));
tr.appendChild(tdShare);
var tdDel = document.createElement('td');
+2 -2
View File
@@ -9,7 +9,7 @@
h1 { color: var(--primary); }
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
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; }
@@ -135,7 +135,7 @@
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - API Tokens';document.querySelector('h1').textContent=d.site_title;}