mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 09:35:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user