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.
This commit is contained in:
Dave Kempe
2026-06-17 21:00:33 +10:00
parent 8b4ea823b1
commit 3e1dcccb83
4 changed files with 264 additions and 0 deletions
+55
View File
@@ -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.
+35
View File
@@ -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<PathBuf>,
/// 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<String>,
/// 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<String>, 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)
+20
View File
@@ -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<String>,
/// 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<String>,
/// 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()
+154
View File
@@ -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<Utc>,
) -> 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::<Vec<_>>()
.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<Utc> {
// 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<Utc>) -> ShadowToken {
use sha2::{Digest, Sha256};
ShadowToken {