From 2dec42d019f9fa7e9e661e906b07cfb065433d68 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Sun, 18 Oct 2026 10:51:06 +1100 Subject: [PATCH] recordings: download as MP4, converted on request with guacenc (#241) A .guac recording only plays in rustguac's own player. The Recordings page now has an mp4 button per recording: it starts a conversion, shows progress, and downloads the video when ready. The file is a standard MP4 that plays in VLC and browsers. - POST /api/recordings/:name/mp4 starts it (202 running, 200 ready, 409 while the session is live, 503 without guacenc), GET .../mp4/status reports it, GET .../mp4 downloads it. Same access and name checks as the recording download (poweruser or admin). - guacenc writes .m4v beside its input, so each job links the recording into a work directory inside the cache and moves the finished file to /.mp4; the recording directory is never written. guacenc exits 0 even on failure, so success is the output existing and not being empty. Runs with a timeout, killed on timeout. - At most mp4_max_jobs at once (default 1). The video is the recording's own size (its largest layer 0 size), scaled down to fit 1920x1080 and rounded to even. - Cached MP4s go after mp4_cache_hours (24), when their recording goes, and all of them before rotation removes any recording for disk space. Leftover work directories are cleared at startup and hourly. - The listing carries each recording's mp4 state only when conversion is available, so the button is hidden on a build without guacenc. Config: mp4_enabled, guacenc_path, mp4_cache_path, mp4_cache_hours, mp4_max_jobs, mp4_timeout_secs, mp4_bitrate, mp4_max_width/height under [recording]. Docs in configuration.md (MP4 downloads, including converting by hand) and api.md, which also had the recordings role as operator; it is poweruser. Tests: the converter (cache layout, failure leaves nothing, live and missing refused, purge, size parsing and fitting) with a stand-in guacenc, the shared access check, and a browser spec for the button. --- docs/api.md | 16 +- docs/configuration.md | 44 ++ src/api.rs | 189 ++++++++ src/config.rs | 66 +++ src/main.rs | 47 +- src/recording_mp4.rs | 556 ++++++++++++++++++++++++ static/recordings.html | 87 +++- tests/e2e/README.md | 1 + tests/e2e/specs/recordings-mp4.spec.mjs | 65 +++ 9 files changed, 1065 insertions(+), 6 deletions(-) create mode 100644 src/recording_mp4.rs create mode 100644 tests/e2e/specs/recordings-mp4.spec.mjs diff --git a/docs/api.md b/docs/api.md index 76a1f79..6a27ed8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -402,11 +402,23 @@ To build your own client, open the WebSocket at `ws_url` with the `guacamole` su ### `GET /api/recordings` -List all recording files. Requires **operator** role or higher. +List all recording files. Requires **poweruser** role or higher. When MP4 conversion is available on the server, each recording has an `mp4` field with the state of its MP4 (see below); without it, the field is left out. ### `GET /api/recordings/:name` -Serve a recording file for playback. Requires **operator** role or higher. Filename is validated against path traversal. +Serve a recording file for playback. Requires **poweruser** role or higher. Filename is validated against path traversal. + +### `POST /api/recordings/:name/mp4` + +Start converting a recording to MP4 with guacenc (see [MP4 downloads](configuration.md#mp4-downloads)). Same access as downloading the recording. Responses: `202` `{"state":"running"}` while it converts (asking again while it runs changes nothing); `200` `{"state":"ready","size_bytes":...}` when a converted copy is already there; `409` while the recording's session is still live; `404` for an unknown recording; `503` when conversion is not available on this server. + +### `GET /api/recordings/:name/mp4/status` + +Where a recording's MP4 stands: `{"state": "none" | "running" | "ready" | "failed" | "unavailable"}`, with `size_bytes` when ready and `error` when failed. A failed conversion is retried by the next `POST`. + +### `GET /api/recordings/:name/mp4` + +Download the converted MP4 (`video/mp4`, saved as `.mp4`). `404` until it is ready. ### `DELETE /api/recordings/:name` diff --git a/docs/configuration.md b/docs/configuration.md index 296ed1f..a2cafe9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,6 +228,14 @@ Controls session recording behaviour and disk management. | `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. | +| `mp4_enabled` | bool | `true` | Offer recordings as MP4 downloads (see below). Off by itself when guacenc is not installed. | +| `guacenc_path` | string | `/opt/rustguac/bin/guacenc` | The guacenc binary that converts recordings. | +| `mp4_cache_path` | string | `/mp4-cache` | Where converted MP4s are kept. | +| `mp4_cache_hours` | integer | `24` | Remove a converted MP4 this many hours after it was made. | +| `mp4_max_jobs` | integer | `1` | Conversions that run at once; more wait their turn. | +| `mp4_timeout_secs` | integer | `1800` | Give up on a conversion after this many seconds. | +| `mp4_bitrate` | integer | `2000000` | Video bitrate, in bits per second. | +| `mp4_max_width`, `mp4_max_height` | integer | `1920`, `1080` | Largest video size. Bigger recordings are scaled down to fit, keeping their shape. | ```toml [recording] @@ -305,6 +313,42 @@ 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. +### MP4 downloads + +A `.guac` recording only plays in rustguac's own player. The Recordings page +can also give you an MP4 that plays anywhere (VLC, a browser, a video +editor): click **mp4** next to a recording. rustguac converts it in the +background with `guacenc`, which comes with guacamole-server, and the file +downloads when it is ready. Converting a long session takes a while, so the +button shows **converting...** until then. + +- The same people can get the MP4 as can download the recording (poweruser + and admin). +- A recording whose session is still running is not converted, since the + file is not finished. +- Converted files are kept in `mp4_cache_path` for `mp4_cache_hours`, so a + second download is instant, and removed when their recording is. When + rotation needs disk space it removes them all before touching any + recording. +- The video is the recording's own size, scaled down to fit + `mp4_max_width` x `mp4_max_height`. +- The button only appears when guacenc is installed. It is part of the + `.deb`, the Docker image and `install.sh` from 1.11.1; on a build without + it the button is not shown. + +To convert recordings yourself, for example a batch for an audit, run +guacenc on the rustguac host. It writes `.m4v` (an MP4 file) +next to the input, so copy recordings somewhere writable first: + +```bash +cp /opt/rustguac/recordings/.guac /tmp/ +/opt/rustguac/bin/guacenc -s 1920x1080 -r 2000000 /tmp/.guac +# -> /tmp/.guac.m4v +``` + +guacenc reports failures in its output but exits 0 either way, so check +that the `.m4v` file exists. + #### Encryption at rest (LUKS) Typescripts are written in plain text. For encryption at rest with no extra diff --git a/src/api.rs b/src/api.rs index 27dfdc7..99eb871 100644 --- a/src/api.rs +++ b/src/api.rs @@ -911,6 +911,7 @@ pub struct CredentialDefaultScope(pub String); pub async fn list_recordings( State(manager): State, identity: Option>, + mp4: Option>>, ) -> impl IntoResponse { match &identity { Some(Extension(id)) if id.has_role("poweruser") => {} @@ -923,6 +924,9 @@ pub async fn list_recordings( } } let recording_path = manager.recording_path().to_path_buf(); + // Each recording's MP4 state (#241), when conversion is available here; + // the field is left out otherwise, which is how the page knows. + let mp4 = mp4.map(|Extension(c)| c).filter(|c| c.enabled()); match tokio::task::spawn_blocking(move || { let mut recordings = Vec::new(); @@ -975,6 +979,9 @@ pub async fn list_recordings( rec["session_type"] = json!(t); } } + if let Some(ref conv) = mp4 { + rec["mp4"] = mp4_state_json(&conv.state(&name))["state"].clone(); + } recordings.push(rec); } recordings.sort_by(|a, b| { @@ -1482,6 +1489,144 @@ pub async fn serve_recording( .into_response() } +/// The check every recording read makes (download, MP4): poweruser or +/// admin, and a plain `.guac` name inside the recording directory. +#[allow(clippy::result_large_err)] +fn check_recording_read( + identity: &Option>, + name: &str, + recording_dir: &std::path::Path, +) -> Result<(), Response> { + match identity { + Some(Extension(id)) if id.has_role("poweruser") => {} + _ => { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "requires poweruser or admin role"})), + ) + .into_response()) + } + } + if !is_safe_recording_name(name, recording_dir) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid recording name"})), + ) + .into_response()); + } + Ok(()) +} + +fn mp4_state_json(state: &crate::recording_mp4::Mp4State) -> serde_json::Value { + use crate::recording_mp4::Mp4State; + match state { + Mp4State::Unavailable => json!({"state": "unavailable"}), + Mp4State::None => json!({"state": "none"}), + Mp4State::Running => json!({"state": "running"}), + Mp4State::Ready { size_bytes } => json!({"state": "ready", "size_bytes": size_bytes}), + Mp4State::Failed(why) => json!({"state": "failed", "error": why}), + } +} + +/// POST /api/recordings/{name}/mp4: Start converting a recording to MP4 +/// (#241). Same access as downloading the recording. 202 while it converts, +/// 200 when a converted copy is already there, 409 while the recording's +/// session is still live, 503 when conversion is not available here. +pub async fn start_recording_mp4( + State(manager): State, + Extension(mp4): Extension>, + identity: Option>, + Path(name): Path, +) -> impl IntoResponse { + use crate::recording_mp4::StartOutcome; + if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) { + return resp; + } + let who = identity + .as_ref() + .map(|Extension(id)| id.display_name().to_string()) + .unwrap_or_default(); + let live = manager + .active_recording_paths() + .await + .contains(&manager.recording_path().join(&name)); + match mp4.start(&name, live, &who) { + StartOutcome::Started | StartOutcome::AlreadyRunning => { + (StatusCode::ACCEPTED, Json(json!({"state": "running"}))).into_response() + } + StartOutcome::AlreadyReady { size_bytes } => { + Json(json!({"state": "ready", "size_bytes": size_bytes})).into_response() + } + StartOutcome::Live => ( + StatusCode::CONFLICT, + Json(json!({"error": "this session is still being recorded; try again once it has ended"})), + ) + .into_response(), + StartOutcome::NotFound => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "recording not found"})), + ) + .into_response(), + StartOutcome::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "MP4 conversion is not available on this server"})), + ) + .into_response(), + } +} + +/// GET /api/recordings/{name}/mp4/status: Where a recording's MP4 stands: +/// `none`, `running`, `ready` (with `size_bytes`), `failed` (with `error`) +/// or `unavailable`. +pub async fn recording_mp4_status( + State(manager): State, + Extension(mp4): Extension>, + identity: Option>, + Path(name): Path, +) -> impl IntoResponse { + if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) { + return resp; + } + Json(mp4_state_json(&mp4.state(&name))).into_response() +} + +/// GET /api/recordings/{name}/mp4: Download the converted MP4, once ready. +pub async fn serve_recording_mp4( + State(manager): State, + Extension(mp4): Extension>, + identity: Option>, + Path(name): Path, +) -> impl IntoResponse { + use crate::recording_mp4::Mp4State; + if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) { + return resp; + } + let not_yet = || { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": "no MP4 yet; start one with POST"})), + ) + .into_response() + }; + if !matches!(mp4.state(&name), Mp4State::Ready { .. }) { + return not_yet(); + } + let file = match tokio::fs::File::open(mp4.mp4_path(&name)).await { + Ok(f) => f, + Err(_) => return not_yet(), + }; + let stem = name.strip_suffix(".guac").unwrap_or(&name); + axum::response::Response::builder() + .header("content-type", "video/mp4") + .header( + "content-disposition", + format!("attachment; filename=\"{}.mp4\"", stem), + ) + .body(Body::from_stream(ReaderStream::new(file))) + .unwrap() + .into_response() +} + /// DELETE /api/recordings/{name} — Delete a recording file. Requires admin. pub async fn delete_recording( State(manager): State, @@ -8686,6 +8831,50 @@ mod tests { assert!(!is_safe_recording_name("..guac", Path::new("/tmp"))); } + #[test] + fn recording_mp4_access_matches_recording_download() { + // The MP4 endpoints (#241) share the download's check: poweruser or + // admin, and a plain .guac name inside the recording directory. + let dir = std::env::temp_dir().join(format!("rustguac-rec-acl-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("s.guac"), "x").unwrap(); + let user = |role: &str| { + Some(Extension(AuthIdentity::User { + email: "u@example.com".into(), + role: role.into(), + groups: vec![], + })) + }; + let status = |id: &Option>, name: &str| { + check_recording_read(id, name, &dir) + .err() + .map(|r| r.status()) + }; + assert_eq!(status(&user("poweruser"), "s.guac"), None); + assert_eq!(status(&user("admin"), "s.guac"), None); + assert_eq!( + status(&Some(Extension(AuthIdentity::ApiKey("k".into()))), "s.guac"), + None + ); + assert_eq!( + status(&user("operator"), "s.guac"), + Some(StatusCode::FORBIDDEN) + ); + assert_eq!( + status(&user("viewer"), "s.guac"), + Some(StatusCode::FORBIDDEN) + ); + assert_eq!(status(&None, "s.guac"), Some(StatusCode::FORBIDDEN)); + for bad in ["../s.guac", "a/s.guac", "s.mp4", "..guac", "s.guac\0"] { + assert_eq!( + status(&user("admin"), bad), + Some(StatusCode::BAD_REQUEST), + "{bad}" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn test_safe_recording_name_extension_case_sensitive() { // Rustguac writes lower-case .guac only; allowing .GUAC creates a diff --git a/src/config.rs b/src/config.rs index 367bb87..1e8117a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -227,6 +227,63 @@ pub struct RecordingConfig { /// Ask guacd to create `typescript_path` if it doesn't already exist. #[serde(default)] pub create_typescript_path: bool, + /// Offer recordings as MP4 downloads, converted on request with guacenc + /// (#241). Default: true. Has no effect when guacenc is not installed. + #[serde(default = "default_true")] + pub mp4_enabled: bool, + /// The guacenc binary. Default: /opt/rustguac/bin/guacenc. + #[serde(default = "default_guacenc_path")] + pub guacenc_path: PathBuf, + /// Where converted MP4s are kept. Default: `mp4-cache` inside the + /// recording directory. Emptied first when rotation needs disk space. + #[serde(default)] + pub mp4_cache_path: Option, + /// Remove a converted MP4 this many hours after it was made. Default: 24. + #[serde(default = "default_mp4_cache_hours")] + pub mp4_cache_hours: u64, + /// Conversions run at once; more wait their turn. Default: 1. + #[serde(default = "default_mp4_max_jobs")] + pub mp4_max_jobs: u32, + /// Give up on a conversion after this many seconds. Default: 1800. + #[serde(default = "default_mp4_timeout_secs")] + pub mp4_timeout_secs: u64, + /// Video bitrate in bits per second. Default: 2000000. + #[serde(default = "default_mp4_bitrate")] + pub mp4_bitrate: u32, + /// Largest video size; bigger recordings are scaled down to fit, keeping + /// their shape. Default: 1920 x 1080. + #[serde(default = "default_mp4_max_width")] + pub mp4_max_width: u32, + #[serde(default = "default_mp4_max_height")] + pub mp4_max_height: u32, +} + +fn default_guacenc_path() -> PathBuf { + PathBuf::from("/opt/rustguac/bin/guacenc") +} + +fn default_mp4_cache_hours() -> u64 { + 24 +} + +fn default_mp4_max_jobs() -> u32 { + 1 +} + +fn default_mp4_timeout_secs() -> u64 { + 1800 +} + +fn default_mp4_bitrate() -> u32 { + 2_000_000 +} + +fn default_mp4_max_width() -> u32 { + 1920 +} + +fn default_mp4_max_height() -> u32 { + 1080 } fn default_max_disk_percent() -> u8 { @@ -248,6 +305,15 @@ impl Default for RecordingConfig { typescript_path: None, typescript_name: None, create_typescript_path: false, + mp4_enabled: true, + guacenc_path: default_guacenc_path(), + mp4_cache_path: None, + mp4_cache_hours: default_mp4_cache_hours(), + mp4_max_jobs: default_mp4_max_jobs(), + mp4_timeout_secs: default_mp4_timeout_secs(), + mp4_bitrate: default_mp4_bitrate(), + mp4_max_width: default_mp4_max_width(), + mp4_max_height: default_mp4_max_height(), } } } diff --git a/src/main.rs b/src/main.rs index b2101a1..413d047 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod oidc; mod protocol; mod pve; mod recording; +mod recording_mp4; mod session; mod tunnel; mod vault; @@ -1236,6 +1237,25 @@ async fn run_server(config: Config, database: Db) { } } + // MP4 downloads of recordings (#241): guacenc on request, cached. + let mp4_converter = Arc::new(recording_mp4::Mp4Converter::new( + &manager.recording_config(), + manager.recording_path(), + )); + { + // Clear out what an earlier run left (work directories, expired and + // orphaned MP4s) now, then every hour. + let conv = mp4_converter.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); + loop { + interval.tick().await; + let c = conv.clone(); + let _ = tokio::task::spawn_blocking(move || c.purge(false)).await; + } + }); + } + // Spawn recording rotation background task { let rec_config = manager.recording_config(); @@ -1248,6 +1268,7 @@ async fn run_server(config: Config, database: Db) { interval_secs ); let rot_manager = manager.clone(); + let rot_conv = mp4_converter.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); @@ -1258,8 +1279,19 @@ async fn run_server(config: Config, database: Db) { // Snapshot recordings of still-live sessions so rotation // never deletes an in-progress (open) recording. let active = rot_manager.active_recording_paths().await; - let _ = - tokio::task::spawn_blocking(move || recording::rotate(&cfg, &active)).await; + let conv = rot_conv.clone(); + let _ = tokio::task::spawn_blocking(move || { + // Converted MP4s are copies, so they go before any + // recording does when the disk is too full. + if cfg.max_disk_percent > 0 + && recording::disk_usage_percent(&cfg.path) + .is_ok_and(|p| p > cfg.max_disk_percent as f64) + { + conv.purge(true); + } + recording::rotate(&cfg, &active) + }) + .await; } }); } @@ -1313,6 +1345,14 @@ async fn run_server(config: Config, database: Db) { .route("/api/recordings", get(api::list_recordings)) .route("/api/recordings/{name}", get(api::serve_recording)) .route("/api/recordings/{name}", delete(api::delete_recording)) + .route( + "/api/recordings/{name}/mp4", + post(api::start_recording_mp4).get(api::serve_recording_mp4), + ) + .route( + "/api/recordings/{name}/mp4/status", + get(api::recording_mp4_status), + ) // #159: typescript register is list-only by design (no serve/delete). .route("/api/typescripts", get(api::list_typescripts)) .route("/api/reports/sessions", get(api::report_sessions)) @@ -1455,7 +1495,8 @@ async fn run_server(config: Config, database: Db) { .layer(Extension(vault_configured.clone())) .layer(Extension(credential_default_scope.clone())) .layer(Extension(database.clone())) - .layer(Extension(ab_changes.clone())); + .layer(Extension(ab_changes.clone())) + .layer(Extension(mp4_converter.clone())); // Applied OUTSIDE require_auth so requests with bad credentials are // counted too: a flood of bogus keys is throttled before each one // queues on the database lock to be looked up. diff --git a/src/recording_mp4.rs b/src/recording_mp4.rs new file mode 100644 index 0000000..745e7b6 --- /dev/null +++ b/src/recording_mp4.rs @@ -0,0 +1,556 @@ +//! MP4 downloads of recordings (#241). +//! +//! A `.guac` recording only plays inside rustguac. guacenc, which comes with +//! guacamole-server, turns one into a video, so a recording can be offered as +//! an MP4 that plays anywhere. Conversion takes a while on a long session, so +//! it runs in the background on request and the result is cached: +//! +//! - guacenc writes `.m4v` next to its input. The recording directory +//! is left alone: each job links the recording into a private work +//! directory inside the cache and converts the link there, then moves the +//! finished file to `/.mp4`. A file at that name is +//! always complete; a failed or abandoned job leaves only its work +//! directory, which is removed. +//! - guacenc exits 0 even when it fails, so success is the output file +//! existing and not being empty. +//! - A recording still being written (its session is live) is not converted. +//! - At most `mp4_max_jobs` conversions run at once; the rest wait. +//! - Cached MP4s go after `mp4_cache_hours`, when their recording goes, and +//! all at once when rotation needs disk space. + +use crate::config::RecordingConfig; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; +use tokio::sync::Semaphore; + +/// Where a recording stands with respect to its MP4. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mp4State { + /// Conversion is turned off or guacenc is not installed. + Unavailable, + /// Not converted, or the conversion has gone from the cache. + None, + /// Being converted, or waiting for a free slot. + Running, + /// Converted: the MP4 is ready to download. + Ready { size_bytes: u64 }, + /// The last attempt failed; asking again retries it. + Failed(String), +} + +/// What happened when a conversion was asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartOutcome { + Started, + AlreadyRunning, + AlreadyReady { + size_bytes: u64, + }, + Unavailable, + /// The recording's session is still live, so the file is incomplete. + Live, + NotFound, +} + +pub struct Mp4Converter { + enabled: bool, + guacenc: PathBuf, + recording_dir: PathBuf, + cache_dir: PathBuf, + cache_max_age: Duration, + timeout: Duration, + bitrate: u32, + max_size: (u32, u32), + slots: Arc, + /// Jobs not yet finished, and the reason for the last failure, by + /// recording name. Absent: nothing in progress (look at the cache). + jobs: Mutex>, +} + +#[derive(Debug, Clone)] +enum Job { + Running, + Failed(String), +} + +impl Mp4Converter { + pub fn new(cfg: &RecordingConfig, recording_dir: &Path) -> Self { + let cache_dir = cfg + .mp4_cache_path + .clone() + .unwrap_or_else(|| recording_dir.join("mp4-cache")); + let installed = is_executable(&cfg.guacenc_path); + if cfg.mp4_enabled && !installed { + tracing::info!( + path = %cfg.guacenc_path.display(), + "guacenc not found, so MP4 downloads of recordings are off" + ); + } + Self { + enabled: cfg.mp4_enabled && installed, + guacenc: cfg.guacenc_path.clone(), + recording_dir: recording_dir.to_path_buf(), + cache_dir, + cache_max_age: Duration::from_secs(cfg.mp4_cache_hours.max(1) * 3600), + timeout: Duration::from_secs(cfg.mp4_timeout_secs.max(10)), + bitrate: cfg.mp4_bitrate.max(100_000), + max_size: (cfg.mp4_max_width.max(16), cfg.mp4_max_height.max(16)), + slots: Arc::new(Semaphore::new(cfg.mp4_max_jobs.max(1) as usize)), + jobs: Mutex::new(HashMap::new()), + } + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + /// The finished MP4 for a recording, once converted. Only ever a + /// complete file: jobs move their output here when done. + pub fn mp4_path(&self, name: &str) -> PathBuf { + let stem = name.strip_suffix(".guac").unwrap_or(name); + self.cache_dir.join(format!("{stem}.mp4")) + } + + /// The cached MP4's size, if it exists and is at least as new as its + /// recording. + fn ready_size(&self, name: &str) -> Option { + let mp4 = std::fs::metadata(self.mp4_path(name)).ok()?; + let guac = std::fs::metadata(self.recording_dir.join(name)).ok()?; + let fresh = match (mp4.modified(), guac.modified()) { + (Ok(m), Ok(g)) => m >= g, + _ => false, + }; + (fresh && mp4.len() > 0).then_some(mp4.len()) + } + + pub fn state(&self, name: &str) -> Mp4State { + if !self.enabled { + return Mp4State::Unavailable; + } + match self.jobs.lock().unwrap().get(name) { + Some(Job::Running) => return Mp4State::Running, + Some(Job::Failed(why)) => return Mp4State::Failed(why.clone()), + None => {} + } + match self.ready_size(name) { + Some(size_bytes) => Mp4State::Ready { size_bytes }, + None => Mp4State::None, + } + } + + /// Start converting `name` unless it is converted, being converted, live + /// or missing. `live` is whether its session is still recording. + pub fn start(self: &Arc, name: &str, live: bool, who: &str) -> StartOutcome { + if !self.enabled { + return StartOutcome::Unavailable; + } + let guac = self.recording_dir.join(name); + if !guac.is_file() { + return StartOutcome::NotFound; + } + if live { + return StartOutcome::Live; + } + { + let mut jobs = self.jobs.lock().unwrap(); + if matches!(jobs.get(name), Some(Job::Running)) { + return StartOutcome::AlreadyRunning; + } + if let Some(size_bytes) = self.ready_size(name) { + jobs.remove(name); + return StartOutcome::AlreadyReady { size_bytes }; + } + jobs.insert(name.to_string(), Job::Running); + } + tracing::info!(user = %who, recording = %name, "MP4 conversion requested"); + let this = Arc::clone(self); + let name = name.to_string(); + tokio::spawn(async move { + let result = this.convert(&name, &guac).await; + let mut jobs = this.jobs.lock().unwrap(); + match result { + Ok(()) => { + tracing::info!(recording = %name, "MP4 conversion finished"); + jobs.remove(&name); + } + Err(why) => { + tracing::warn!(recording = %name, reason = %why, "MP4 conversion failed"); + jobs.insert(name, Job::Failed(why)); + } + } + }); + StartOutcome::Started + } + + async fn convert(&self, name: &str, guac: &Path) -> Result<(), String> { + let _slot = self + .slots + .clone() + .acquire_owned() + .await + .map_err(|_| "converter shut down".to_string())?; + tokio::fs::create_dir_all(&self.cache_dir) + .await + .map_err(|e| format!("cannot create the MP4 cache: {e}"))?; + let work = self + .cache_dir + .join(format!(".work-{}", uuid::Uuid::new_v4())); + let result = self.convert_in(name, guac, &work).await; + let _ = tokio::fs::remove_dir_all(&work).await; + result + } + + async fn convert_in(&self, name: &str, guac: &Path, work: &Path) -> Result<(), String> { + tokio::fs::create_dir(work) + .await + .map_err(|e| format!("cannot create a work directory: {e}"))?; + let source = tokio::fs::canonicalize(guac) + .await + .map_err(|_| "the recording has gone".to_string())?; + let link = work.join(name); + tokio::fs::symlink(&source, &link) + .await + .map_err(|e| format!("cannot link the recording: {e}"))?; + + let size = { + let source = source.clone(); + tokio::task::spawn_blocking(move || recording_size(&source)) + .await + .ok() + .flatten() + }; + let (w, h) = fit_size(size, self.max_size); + + let mut child = tokio::process::Command::new(&self.guacenc) + .arg("-s") + .arg(format!("{w}x{h}")) + .arg("-r") + .arg(self.bitrate.to_string()) + .arg(&link) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("cannot run guacenc: {e}"))?; + match tokio::time::timeout(self.timeout, child.wait()).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => return Err(format!("guacenc did not finish: {e}")), + Err(_) => { + let _ = child.kill().await; + return Err(format!( + "took longer than {} seconds", + self.timeout.as_secs() + )); + } + } + + // guacenc exits 0 whatever happened: the output is the verdict. + let produced = work.join(format!("{name}.m4v")); + match tokio::fs::metadata(&produced).await { + Ok(m) if m.len() > 0 => {} + _ => return Err("guacenc produced no video".to_string()), + } + tokio::fs::rename(&produced, self.mp4_path(name)) + .await + .map_err(|e| format!("cannot store the MP4: {e}")) + } + + /// Remove cached MP4s older than `mp4_cache_hours` or whose recording + /// has gone (all of them when `everything`), and work directories of + /// jobs no longer running. Returns how many files went. + pub fn purge(&self, everything: bool) -> usize { + let entries = match std::fs::read_dir(&self.cache_dir) { + Ok(e) => e, + Err(_) => return 0, + }; + let running = self + .jobs + .lock() + .unwrap() + .values() + .any(|j| matches!(j, Job::Running)); + let now = SystemTime::now(); + let mut removed = 0; + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with(".work-") { + // A work directory belongs to a running job, or to one that + // died with the process. + if !running && std::fs::remove_dir_all(&path).is_ok() { + removed += 1; + } + continue; + } + let Some(stem) = file_name.strip_suffix(".mp4") else { + continue; + }; + let orphaned = !self.recording_dir.join(format!("{stem}.guac")).exists(); + let expired = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| now.duration_since(t).ok()) + .is_some_and(|age| age > self.cache_max_age); + if (everything || orphaned || expired) && std::fs::remove_file(&path).is_ok() { + removed += 1; + } + } + removed + } +} + +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +/// The size of a recording's display: the largest size given to the default +/// layer (layer 0) in its first few megabytes, which is where the session +/// sets it. `None` when none is found. +pub fn recording_size(path: &Path) -> Option<(u32, u32)> { + use std::io::Read; + let mut head = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(4 * 1024 * 1024) + .read_to_end(&mut head) + .ok()?; + largest_layer0_size(&String::from_utf8_lossy(&head)) +} + +/// Walk Guacamole instructions (`LEN.VALUE,LEN.VALUE;`, lengths counted in +/// characters) and return the largest `size` of layer 0. Stops at the first +/// malformed or truncated element. +fn largest_layer0_size(text: &str) -> Option<(u32, u32)> { + let mut chars = text.chars().peekable(); + let mut best: Option<(u32, u32)> = None; + let mut instruction: Vec = Vec::new(); + loop { + let mut len = String::new(); + while let Some(c) = chars.peek() { + if c.is_ascii_digit() { + len.push(*c); + chars.next(); + } else { + break; + } + } + if len.is_empty() || chars.next() != Some('.') { + return best; + } + let n: usize = len.parse().ok()?; + let value: String = chars.by_ref().take(n).collect(); + if value.chars().count() != n { + return best; + } + instruction.push(value); + match chars.next() { + Some(',') => {} + Some(';') => { + if instruction.len() == 4 && instruction[0] == "size" && instruction[1] == "0" { + if let (Ok(w), Ok(h)) = (instruction[2].parse(), instruction[3].parse()) { + let area = |(w, h): (u32, u32)| u64::from(w) * u64::from(h); + if best.is_none_or(|b| area((w, h)) > area(b)) { + best = Some((w, h)); + } + } + } + instruction.clear(); + } + _ => return best, + } + } +} + +/// The video size for a recording: its own size, scaled down to fit within +/// `max` keeping its shape, rounded down to even numbers (the encoder needs +/// them). 1280 x 720 when the recording's size is unknown. +pub fn fit_size(size: Option<(u32, u32)>, max: (u32, u32)) -> (u32, u32) { + let (w, h) = match size { + Some((w, h)) if w > 0 && h > 0 => (w, h), + _ => (1280, 720), + }; + let scale = (max.0 as f64 / w as f64) + .min(max.1 as f64 / h as f64) + .min(1.0); + let even = |v: f64| ((v.floor() as u32) & !1).max(2); + (even(w as f64 * scale), even(h as f64 * scale)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(dir: &Path, guacenc: &Path) -> RecordingConfig { + RecordingConfig { + path: dir.to_path_buf(), + guacenc_path: guacenc.to_path_buf(), + ..RecordingConfig::default() + } + } + + struct Scratch(PathBuf); + impl Scratch { + fn new(name: &str) -> Self { + let p = + std::env::temp_dir().join(format!("rustguac-mp4-{}-{}", name, std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + Scratch(p) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A stand-in guacenc: a shell script that behaves like the real one + /// (writes `.m4v` next to its input and exits 0), or one that + /// writes nothing, as guacenc does when it fails. + fn fake_guacenc(dir: &Path, works: bool) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let p = dir.join(if works { "guacenc-ok" } else { "guacenc-bad" }); + let body = if works { + "#!/bin/sh\nfor a; do last=\"$a\"; done\nprintf video > \"$last.m4v\"\nexit 0\n" + } else { + "#!/bin/sh\nexit 0\n" + }; + std::fs::write(&p, body).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + } + + async fn settle(conv: &Arc, name: &str) -> Mp4State { + for _ in 0..200 { + let s = conv.state(name); + if s != Mp4State::Running { + return s; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("conversion never finished"); + } + + #[test] + fn finds_the_recordings_own_size() { + let rec = "4.size,2.-6,2.41,2.39;4.size,1.0,4.1280,3.720;4.size,1.1,2.10,2.10;\ + 4.size,1.0,4.2556,4.1278;3.png,1.0,2.ab;"; + assert_eq!(largest_layer0_size(rec), Some((2556, 1278))); + // Lengths count characters, not bytes. + assert_eq!( + largest_layer0_size("4.name,3.é?é;4.size,1.0,3.800,3.600;"), + Some((800, 600)) + ); + assert_eq!(largest_layer0_size("4.sync,1.0;"), None); + // A truncated tail keeps what was found before it. + assert_eq!( + largest_layer0_size("4.size,1.0,3.800,3.600;4.size,1.0,4.99"), + Some((800, 600)) + ); + } + + #[test] + fn video_size_fits_and_is_even() { + assert_eq!(fit_size(Some((1280, 720)), (1920, 1080)), (1280, 720)); + assert_eq!(fit_size(Some((2556, 1278)), (1920, 1080)), (1920, 960)); + assert_eq!(fit_size(Some((1023, 767)), (1920, 1080)), (1022, 766)); + assert_eq!(fit_size(None, (1920, 1080)), (1280, 720)); + assert_eq!(fit_size(Some((0, 0)), (1920, 1080)), (1280, 720)); + } + + #[tokio::test] + async fn converts_into_the_cache_and_leaves_the_recordings_alone() { + let s = Scratch::new("convert"); + let rec = s.0.join("rec"); + std::fs::create_dir_all(&rec).unwrap(); + std::fs::write(rec.join("a.guac"), "4.size,1.0,4.1280,3.720;").unwrap(); + let conv = Arc::new(Mp4Converter::new( + &config(&rec, &fake_guacenc(&s.0, true)), + &rec, + )); + assert_eq!(conv.state("a.guac"), Mp4State::None); + assert_eq!(conv.start("a.guac", false, "u"), StartOutcome::Started); + assert_eq!( + settle(&conv, "a.guac").await, + Mp4State::Ready { size_bytes: 5 } + ); + assert!(conv.mp4_path("a.guac").ends_with("mp4-cache/a.mp4")); + // Nothing new beside the recording, and no work directory left. + let beside: Vec<_> = std::fs::read_dir(&rec) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(beside.len(), 2, "{beside:?}"); + let cached: Vec<_> = std::fs::read_dir(rec.join("mp4-cache")) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(cached, vec!["a.mp4".to_string()]); + // Asking again reuses it. + assert_eq!( + conv.start("a.guac", false, "u"), + StartOutcome::AlreadyReady { size_bytes: 5 } + ); + } + + #[tokio::test] + async fn no_output_is_a_failure_and_leaves_nothing() { + let s = Scratch::new("fail"); + let rec = s.0.join("rec"); + std::fs::create_dir_all(&rec).unwrap(); + std::fs::write(rec.join("b.guac"), "x").unwrap(); + let conv = Arc::new(Mp4Converter::new( + &config(&rec, &fake_guacenc(&s.0, false)), + &rec, + )); + assert_eq!(conv.start("b.guac", false, "u"), StartOutcome::Started); + assert!(matches!(settle(&conv, "b.guac").await, Mp4State::Failed(_))); + assert_eq!(std::fs::read_dir(rec.join("mp4-cache")).unwrap().count(), 0); + } + + #[test] + fn live_missing_and_unavailable_are_not_converted() { + let s = Scratch::new("refuse"); + let rec = s.0.join("rec"); + std::fs::create_dir_all(&rec).unwrap(); + std::fs::write(rec.join("c.guac"), "x").unwrap(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let _g = rt.enter(); + let conv = Arc::new(Mp4Converter::new( + &config(&rec, &fake_guacenc(&s.0, true)), + &rec, + )); + assert_eq!(conv.start("c.guac", true, "u"), StartOutcome::Live); + assert_eq!(conv.start("gone.guac", false, "u"), StartOutcome::NotFound); + let off = Arc::new(Mp4Converter::new( + &config(&rec, &s.0.join("no-such-guacenc")), + &rec, + )); + assert!(!off.enabled()); + assert_eq!(off.start("c.guac", false, "u"), StartOutcome::Unavailable); + assert_eq!(off.state("c.guac"), Mp4State::Unavailable); + } + + #[test] + fn purge_removes_orphans_work_dirs_and_everything_on_request() { + let s = Scratch::new("purge"); + let rec = s.0.join("rec"); + let cache = rec.join("mp4-cache"); + std::fs::create_dir_all(cache.join(".work-old")).unwrap(); + std::fs::write(rec.join("kept.guac"), "x").unwrap(); + std::fs::write(cache.join("kept.mp4"), "v").unwrap(); + std::fs::write(cache.join("orphan.mp4"), "v").unwrap(); + let conv = Mp4Converter::new(&config(&rec, &fake_guacenc(&s.0, true)), &rec); + assert_eq!(conv.purge(false), 2, "the orphan and the dead work dir"); + assert!(cache.join("kept.mp4").exists()); + assert_eq!(conv.purge(true), 1); + assert!(!cache.join("kept.mp4").exists()); + } +} diff --git a/static/recordings.html b/static/recordings.html index 5ff661c..5dec18b 100644 --- a/static/recordings.html +++ b/static/recordings.html @@ -439,9 +439,88 @@ function renderRecordings(recordings) { recView = sortRecordings(recordings, recSortCol, recSortAsc); + // The server lists an `mp4` state per recording only when it can + // convert them (#241). A state this page is tracking (a + // conversion it started) wins over a stale listing. + mp4Available = recordings.some(function(r) { return 'mp4' in r; }); + recordings.forEach(function(r) { + if ('mp4' in r && !mp4Polling[r.name]) mp4State[r.name] = r.mp4; + }); renderRecPage(); } + // ── MP4 downloads (#241) ── + // Start a conversion, show its progress on the button by polling the + // status, and download the file as soon as it is ready. Downloads go + // through fetch so API-key sessions send their header, and are saved + // with a.download, never opened as a page. + var mp4Available = false; + var mp4State = {}; // recording name -> none|running|ready|failed + var mp4Polling = {}; // recording name -> true while this page polls + var MP4_LABELS = { running: 'converting\u2026', ready: 'mp4 \u2193', failed: 'mp4 (retry)' }; + + function mp4Button(name) { + var st = mp4State[name] || 'none'; + return ''; + } + + function setMp4State(name, st) { + mp4State[name] = st; + var btn = document.querySelector('[data-mp4="' + CSS.escape(name) + '"]'); + if (btn) btn.outerHTML = mp4Button(name); + } + + function mp4Url(name, suffix) { + return '/api/recordings/' + encodeURIComponent(name) + '/mp4' + (suffix || ''); + } + + function downloadMp4(name) { + fetch(mp4Url(name), { headers: apiHeaders(), credentials: 'same-origin' }) + .then(function(res) { + if (!res.ok) throw new Error('The MP4 is no longer available; convert it again.'); + return res.blob(); + }) + .then(function(blob) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name.replace(/\.guac$/, '') + '.mp4'; + a.click(); + setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000); + }) + .catch(function(err) { setMp4State(name, 'none'); rgAlert(err.message); }); + } + + function pollMp4(name) { + mp4Polling[name] = true; + fetch(mp4Url(name, '/status'), { headers: apiHeaders(), credentials: 'same-origin' }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.state === 'running') { setTimeout(function() { pollMp4(name); }, 2000); return; } + delete mp4Polling[name]; + setMp4State(name, data.state); + if (data.state === 'ready') downloadMp4(name); + else if (data.state === 'failed') rgAlert('MP4 conversion failed: ' + (data.error || 'unknown error')); + }) + .catch(function() { setTimeout(function() { pollMp4(name); }, 5000); }); + } + + function startMp4(name) { + if (mp4State[name] === 'ready') { downloadMp4(name); return; } + setMp4State(name, 'running'); + fetch(mp4Url(name), { method: 'POST', headers: apiHeaders(), credentials: 'same-origin' }) + .then(function(res) { return res.json().then(function(data) { return { res: res, data: data }; }); }) + .then(function(r) { + if (r.res.status === 200 && r.data.state === 'ready') { setMp4State(name, 'ready'); downloadMp4(name); return; } + if (r.res.status === 202) { pollMp4(name); return; } + setMp4State(name, 'none'); + rgAlert(r.data.error || 'Could not start the MP4 conversion.'); + }) + .catch(function() { setMp4State(name, 'none'); rgAlert('Could not start the MP4 conversion.'); }); + } + function renderRecPage() { var container = document.getElementById('recording-list'); var pager = document.getElementById('rec-pagination'); @@ -465,7 +544,7 @@ sortHeader('Folder', 'folder') + sortHeader('Size', 'size_bytes') + sortHeader('Date', 'modified') + - '' + + '' + (mp4Available ? '' : '') + '' + ''; slice.forEach(function(r) { var date = r.modified ? new Date(r.modified).toLocaleString() : (r.created_at ? new Date(r.created_at).toLocaleString() : ''); @@ -480,6 +559,7 @@ '' + date + '' + '' + 'download' + + (mp4Available ? '' + mp4Button(r.name) + '' : '') + '' + ''; }); @@ -506,6 +586,11 @@ playRecording(playName); return; } + var mp4Name = e.target.getAttribute('data-mp4'); + if (mp4Name) { + startMp4(mp4Name); + return; + } var deleteName = e.target.getAttribute('data-delete-rec'); if (deleteName) { rgConfirm('Delete recording ' + deleteName + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) { diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 5d2f65c..a6fc68c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -35,6 +35,7 @@ One spec per change, named after the branch that introduced it: | `global-search` | `/` opens the overlay on every signed-in page, folder view untouched; nothing on the login page, for anonymous or viewer callers, or from a previous login's cache | | `search-under-modals` | a page modal covers the search box | | `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport | +| `recordings-mp4` | #241: the mp4 button converts, shows progress and downloads; refused while live; hidden without server support | The fixture suppresses the first-visit onboarding tour (`options.onboarding` re-enables it) and defaults to the admin role; pass `role` to test gating. diff --git a/tests/e2e/specs/recordings-mp4.spec.mjs b/tests/e2e/specs/recordings-mp4.spec.mjs new file mode 100644 index 0000000..2e9a8f5 --- /dev/null +++ b/tests/e2e/specs/recordings-mp4.spec.mjs @@ -0,0 +1,65 @@ +// #241: a recording can be downloaded as an MP4. The button starts a +// conversion on the server, shows its progress, and downloads the video as +// soon as it is ready, saved under the recording's name. Without MP4 support +// on the server (no `mp4` field in the listing) there is no button. +import { test, expect } from '@playwright/test'; +import { installApi, settle, fixtureRecordings } from '../lib/api.mjs'; + +const NAME = 'rdp-dc01-20261011-020000.guac'; +const MP4 = '/api/recordings/' + encodeURIComponent(NAME) + '/mp4'; + +test('mp4 converts, shows progress, then downloads', async ({ page }) => { + let polls = 0; + const recordings = fixtureRecordings().map(r => ({ ...r, mp4: 'none' })); + const api = await installApi(page, { + recordings, + routes: { + [`POST ${MP4}`]: { status: 202, contentType: 'application/json', body: '{"state":"running"}' }, + // Running for two polls, then ready. + [`GET ${MP4}/status`]: () => (++polls < 3 ? { state: 'running' } : { state: 'ready', size_bytes: 5 }), + [`GET ${MP4}`]: { status: 200, contentType: 'video/mp4', body: 'video' } + } + }); + await page.goto('/recordings.html'); + await settle(page); + + const btn = page.locator(`[data-mp4="${NAME}"]`); + await expect(btn).toHaveText('mp4'); + const download = page.waitForEvent('download', { timeout: 15000 }); + await btn.click(); + await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText(/converting/); + const file = await download; + expect(file.suggestedFilename()).toBe('rdp-dc01-20261011-020000.mp4'); + await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText(/mp4 ↓/); + expect(api.calls.filter(c => c.method === 'POST' && c.path === MP4)).toHaveLength(1); + + // Ready: a second click downloads straight away, with no new conversion. + const again = page.waitForEvent('download'); + await page.locator(`[data-mp4="${NAME}"]`).click(); + await again; + expect(api.calls.filter(c => c.method === 'POST' && c.path === MP4)).toHaveLength(1); +}); + +test('a live recording is refused with a message, and the button resets', async ({ page }) => { + const recordings = fixtureRecordings().map(r => ({ ...r, mp4: 'none' })); + await installApi(page, { + recordings, + routes: { + [`POST ${MP4}`]: { status: 409, contentType: 'application/json', + body: '{"error":"this session is still being recorded; try again once it has ended"}' } + } + }); + await page.goto('/recordings.html'); + await settle(page); + await page.locator(`[data-mp4="${NAME}"]`).click(); + await expect(page.getByText('still being recorded')).toBeVisible(); + await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText('mp4'); +}); + +test('no mp4 button when the server cannot convert', async ({ page }) => { + await installApi(page); + await page.goto('/recordings.html'); + await settle(page); + await expect(page.locator('#recording-list tbody tr').first()).toBeVisible(); + await expect(page.locator('[data-mp4]')).toHaveCount(0); +});