From 3e1dcccb83fcadfa94a7bbd60d505c146d30ac5c Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Wed, 17 Jun 2026 21:00:33 +1000 Subject: [PATCH] feat(ssh): typescript recording passthrough (#159) Expose guacd's SSH typescript recording via a [recording] config block: typescript_path / typescript_name / create_typescript_path. When typescript_path is set, guacd writes a plain-text log of the full terminal session (scriptreplay-compatible, greppable) for every SSH session. Aimed at audit/compliance on network gear. guacd does not template the typescript filename (it uses the name verbatim and only appends a numeric suffix to avoid clobbering), so rustguac expands its own brace tokens before passing the name on: {user} {connection} {host} {date} {time} {session}. Substituted values are sanitised to [A-Za-z0-9_-], so OIDC emails and free-text entry names can't produce path separators or traversal. Default template is {connection}-{user}-{date}-{time} for identifiable audit filenames. recording-include-keys (keystroke logging in guacd's graphical recording, for guaclog) is intentionally not wired up: rustguac records the proxied stream itself rather than driving guacd-side graphical recording, so that flag would be a no-op. The typescript is the supported text-audit path. Docs in configuration.md. 5 unit tests covering token expansion, the default template, sanitisation/traversal, empty-value fallback, and unknown tokens. Closes #159. --- docs/configuration.md | 55 +++++++++++++++ src/config.rs | 35 ++++++++++ src/guacd.rs | 20 ++++++ src/session.rs | 154 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 966e3c8..1c36b10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,6 +181,9 @@ Controls session recording behaviour and disk management. | `max_disk_percent` | integer | `80` | Delete oldest recordings when disk usage exceeds this percent. 0 = disabled. | | `max_recordings` | integer | `0` | Keep at most this many recordings globally. 0 = unlimited. | | `rotation_interval_secs` | integer | `300` | How often (seconds) to run the rotation check. | +| `typescript_path` | string | (unset) | Directory for SSH typescript (raw terminal text) files. Unset = disabled. See below. | +| `typescript_name` | string | `{connection}-{user}-{date}-{time}` | Filename template for typescripts. Tokens listed below. | +| `create_typescript_path` | bool | `false` | Ask guacd to create `typescript_path` if it does not exist. | ```toml [recording] @@ -190,6 +193,58 @@ max_recordings = 1000 rotation_interval_secs = 300 ``` +### SSH typescript recording + +The graphical recording above captures the session as a replayable +Guacamole stream. For SSH sessions you can additionally write a +**typescript**: a plain-text log of the full terminal output, compatible +with the standard `script` / `scriptreplay` tools and trivially +greppable. This is aimed at audit and compliance (a human-readable record +of what was typed and seen on a switch or server). + +Set `typescript_path` to enable it for all SSH sessions. The typescript +is produced by guacd, so the path must be writable by the guacd process +(on a bare-metal install that is the `rustguac-guacd` service user; in +Docker it is inside the container). guacd writes two files per session, +`NAME` and `NAME.timing`. + +```toml +[recording] +typescript_path = "/opt/rustguac/data/typescripts" +typescript_name = "{connection}-{user}-{date}-{time}" +create_typescript_path = true +``` + +**Filename tokens.** guacd does not template typescript names itself (it +uses the name verbatim and only appends a numeric suffix to avoid +overwriting an existing file). rustguac therefore expands its own tokens +in `typescript_name` before handing it over, so each file is identifiable: + +| Token | Expands to | +|-------|-----------| +| `{user}` | Session username | +| `{connection}` | Address-book entry name (falls back to the hostname for ad-hoc sessions) | +| `{host}` | Target hostname | +| `{date}` | Connect date, UTC `YYYYMMDD` | +| `{time}` | Connect time, UTC `HHMMSS` | +| `{session}` | First 8 characters of the session id | + +Substituted values are sanitised to `[A-Za-z0-9_-]` (everything else +becomes `-`), so usernames like `alice@example.com` and free-text entry +names are always reduced to a safe basename with no path separators. +Unknown `{tokens}` are left untouched. + +> **Note:** these are rustguac's own tokens, not guacd's, and they are +> unrelated to [credential variables](credential-variables.md) (which use +> `$name` syntax and apply only to connection-entry credential fields). +> guacd's own `${GUAC_*}` tokens are **not** interpreted for typescripts. + +Keystroke logging in the *graphical* recording (guacd's +`recording-include-keys`, parseable by `guaclog`) is a separate mechanism +that depends on guacd-driven graphical recording, which rustguac does not +use (it records the proxied stream itself). It is therefore not wired up; +the typescript is the supported text-audit path. + ## `[vdi]` section Enables VDI (Virtual Desktop Infrastructure) sessions using Docker containers. Each user gets an ephemeral Linux desktop in a Docker container, accessed via xrdp through guacd. diff --git a/src/config.rs b/src/config.rs index a37e933..2b741ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -201,6 +201,25 @@ pub struct RecordingConfig { /// How often (in seconds) to run the rotation check. Default: 300 (5 min). #[serde(default = "default_rotation_interval_secs")] pub rotation_interval_secs: u64, + /// Directory guacd writes SSH typescript (raw terminal text) files to + /// (#159). When unset, no typescript is recorded. This is a guacd-side + /// path: the guacd process must be able to write here. Applies to all + /// SSH sessions, independent of the graphical (.guac) recording above. + #[serde(default)] + pub typescript_path: Option, + /// Base filename template for the typescript. guacd itself does NOT + /// substitute tokens in this name (it uses it verbatim and appends a + /// numeric suffix to avoid collisions), so rustguac expands its own + /// brace tokens before passing it on: `{user}`, `{connection}` + /// (address-book entry name, falls back to hostname), `{host}`, + /// `{date}` (UTC YYYYMMDD), `{time}` (UTC HHMMSS), `{session}` (short + /// session id). Substituted values are sanitised to `[A-Za-z0-9_-]`. + /// Defaults to `{connection}-{user}-{date}-{time}` when unset. + #[serde(default)] + pub typescript_name: Option, + /// Ask guacd to create `typescript_path` if it doesn't already exist. + #[serde(default)] + pub create_typescript_path: bool, } fn default_max_disk_percent() -> u8 { @@ -219,6 +238,9 @@ impl Default for RecordingConfig { max_disk_percent: default_max_disk_percent(), max_recordings: 0, rotation_interval_secs: default_rotation_interval_secs(), + typescript_path: None, + typescript_name: None, + create_typescript_path: false, } } } @@ -1218,6 +1240,19 @@ impl Config { } } + /// SSH typescript recording settings (#159), if `[recording] + /// typescript_path` is configured. Returns `(path, name, create)` + /// ready to hand to guacd; `None` means no typescript. + pub fn ssh_typescript(&self) -> Option<(String, Option, bool)> { + let rec = self.recording.as_ref()?; + let path = rec.typescript_path.as_ref()?; + Some(( + path.to_string_lossy().into_owned(), + rec.typescript_name.clone(), + rec.create_typescript_path, + )) + } + /// Whether recording is globally enabled. Defaults to true. pub fn recording_enabled(&self) -> bool { self.recording.as_ref().is_none_or(|r| r.enabled) diff --git a/src/guacd.rs b/src/guacd.rs index 9b78c31..bd24482 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -43,6 +43,18 @@ pub struct SshParams { pub sftp_disable_upload: bool, pub disable_copy: bool, pub disable_paste: bool, + /// SSH typescript recording (#159). guacd writes the raw terminal + /// session to a plain-text file (compatible with `scriptreplay`). + /// An empty `typescript_path` disables it (guacd records nothing). + /// These are guacd-side paths: the guacd process must be able to + /// write to `typescript_path`. + pub typescript_path: Option, + /// Base filename for the typescript, already expanded by rustguac + /// (guacd does not substitute tokens here). Empty falls back to + /// guacd's own default of "typescript". + pub typescript_name: Option, + /// Ask guacd to create `typescript_path` if it doesn't exist. + pub create_typescript_path: bool, } /// VNC connection parameters to pass to guacd. @@ -200,6 +212,14 @@ pub async fn connect_and_handshake( "locale" => "en_US.UTF-8".into(), "server-alive-interval" => "0".into(), "command" => String::new(), + "typescript-path" => p.typescript_path.clone().unwrap_or_default(), + "typescript-name" => p.typescript_name.clone().unwrap_or_default(), + "create-typescript-path" => if p.create_typescript_path { + "true" + } else { + "false" + } + .into(), _ => { tracing::debug!("Unknown guacd SSH parameter '{}', sending empty", name); String::new() diff --git a/src/session.rs b/src/session.rs index 24a2087..70f48a7 100644 --- a/src/session.rs +++ b/src/session.rs @@ -675,6 +675,26 @@ 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); + // SSH typescript recording (#159): rustguac expands the + // name template (guacd uses it verbatim) so audit files + // are identifiable per user + connection. + let typescript = self.config.ssh_typescript().map(|(path, name, create)| { + let template = name.as_deref().unwrap_or(DEFAULT_TYPESCRIPT_NAME); + let connection = req + .entry_display_name + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&hostname); + let expanded = expand_typescript_name( + template, + &username, + &hostname, + connection, + &session_id, + Utc::now(), + ); + (path, expanded, create) + }); let params = guacd::ConnectionParams::Ssh(guacd::SshParams { hostname: hostname.clone(), port, @@ -689,6 +709,12 @@ impl SessionManager { sftp_disable_upload: !drive_cfg.allow_upload, disable_copy: req.disable_copy.unwrap_or(false), disable_paste: req.disable_paste.unwrap_or(false), + typescript_path: typescript.as_ref().map(|(p, _, _)| p.clone()), + typescript_name: typescript.as_ref().map(|(_, n, _)| n.clone()), + create_typescript_path: typescript + .as_ref() + .map(|(_, _, c)| *c) + .unwrap_or(false), }); ( params, hostname, username, None, None, ssh_banner, None, None, None, @@ -1790,6 +1816,65 @@ impl SessionManager { } } +/// Default typescript filename template when `[recording].typescript_name` +/// is unset. Produces audit-friendly per-session names (#159). +const DEFAULT_TYPESCRIPT_NAME: &str = "{connection}-{user}-{date}-{time}"; + +/// Expand rustguac's brace tokens in a typescript filename template (#159). +/// +/// guacd uses the typescript name verbatim (it appends a numeric suffix +/// only to avoid clobbering an existing file), so rustguac does this +/// substitution itself to produce audit-friendly, per-session filenames +/// like `coreswitch01-alice-20260610-143022`. Every substituted value is +/// sanitised to `[A-Za-z0-9_-]`, so the result is always a safe basename: +/// no path separators, no traversal, no surprises from OIDC usernames or +/// free-text entry names. +/// +/// Tokens: `{user}`, `{connection}`, `{host}`, `{date}` (UTC YYYYMMDD), +/// `{time}` (UTC HHMMSS), `{session}` (first 8 chars of the session id). +/// Unknown braces are left untouched. +fn expand_typescript_name( + template: &str, + username: &str, + hostname: &str, + connection: &str, + session_id: &Uuid, + when: DateTime, +) -> String { + fn sanitize(s: &str) -> String { + let mapped: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + let collapsed = mapped + .split('-') + .filter(|p| !p.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + "unknown".to_string() + } else { + collapsed + } + } + + let short_session: String = session_id.simple().to_string().chars().take(8).collect(); + + template + .replace("{user}", &sanitize(username)) + .replace("{connection}", &sanitize(connection)) + .replace("{host}", &sanitize(hostname)) + .replace("{date}", &when.format("%Y%m%d").to_string()) + .replace("{time}", &when.format("%H%M%S").to_string()) + .replace("{session}", &short_session) +} + /// Parse autofill credentials JSON and substitute $USERNAME/$PASSWORD placeholders. /// Returns None if autofill is not configured or the JSON is invalid. fn parse_autofill_credentials( @@ -1948,6 +2033,75 @@ pub(crate) fn check_share_token_match( mod tests { use super::*; + // ── Typescript filename templating (#159) ── + + fn ts_when() -> DateTime { + // 2026-06-10 14:30:22 UTC + DateTime::from_timestamp(1_781_101_822, 0).unwrap() + } + + fn ts_id() -> Uuid { + Uuid::parse_str("0123abcd-1111-2222-3333-444455556666").unwrap() + } + + #[test] + fn typescript_name_expands_all_tokens() { + let got = expand_typescript_name( + "{connection}-{user}-{date}-{time}-{host}-{session}", + "alice", + "switch01", + "Core Switch 01", + &ts_id(), + ts_when(), + ); + assert_eq!( + got, + "Core-Switch-01-alice-20260610-143022-switch01-0123abcd" + ); + } + + #[test] + fn typescript_name_default_template() { + let got = expand_typescript_name( + DEFAULT_TYPESCRIPT_NAME, + "bob", + "rtr-2", + "Edge Router", + &ts_id(), + ts_when(), + ); + assert_eq!(got, "Edge-Router-bob-20260610-143022"); + } + + #[test] + fn typescript_name_sanitises_path_traversal_and_oidc_email() { + // A crafted entry name must not escape the typescript dir, and an + // OIDC email username must reduce to a safe basename. + let got = expand_typescript_name( + "{connection}-{user}", + "alice@sol1.com.au", + "h", + "../../etc/cron.d/evil", + &ts_id(), + ts_when(), + ); + assert!(!got.contains('/'), "no path separators: {got}"); + assert!(!got.contains(".."), "no traversal: {got}"); + assert_eq!(got, "etc-cron-d-evil-alice-sol1-com-au"); + } + + #[test] + fn typescript_name_empty_value_falls_back_to_unknown() { + let got = expand_typescript_name("{user}", "", "h", "c", &ts_id(), ts_when()); + assert_eq!(got, "unknown"); + } + + #[test] + fn typescript_name_unknown_token_left_literal() { + let got = expand_typescript_name("pre-{bogus}-{user}", "x", "h", "c", &ts_id(), ts_when()); + assert_eq!(got, "pre-{bogus}-x"); + } + fn make_shadow(raw: &str, issued_by: &str, expires_at: DateTime) -> ShadowToken { use sha2::{Digest, Sha256}; ShadowToken {