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 <input>.m4v beside its input, so each job links the
  recording into a work directory inside the cache and moves the
  finished file to <cache>/<stem>.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.
This commit is contained in:
Dave Kempe
2026-10-18 10:51:06 +11:00
parent 994f138685
commit 2dec42d019
9 changed files with 1065 additions and 6 deletions
+14 -2
View File
@@ -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 `<recording>.mp4`). `404` until it is ready.
### `DELETE /api/recordings/:name`
+44
View File
@@ -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 | `<path>/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 `<recording>.m4v` (an MP4 file)
next to the input, so copy recordings somewhere writable first:
```bash
cp /opt/rustguac/recordings/<id>.guac /tmp/
/opt/rustguac/bin/guacenc -s 1920x1080 -r 2000000 /tmp/<id>.guac
# -> /tmp/<id>.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
+189
View File
@@ -911,6 +911,7 @@ pub struct CredentialDefaultScope(pub String);
pub async fn list_recordings(
State(manager): State<AppState>,
identity: Option<Extension<AuthIdentity>>,
mp4: Option<Extension<Arc<crate::recording_mp4::Mp4Converter>>>,
) -> 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<Extension<AuthIdentity>>,
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<AppState>,
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
identity: Option<Extension<AuthIdentity>>,
Path(name): Path<String>,
) -> 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<AppState>,
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
identity: Option<Extension<AuthIdentity>>,
Path(name): Path<String>,
) -> 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<AppState>,
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
identity: Option<Extension<AuthIdentity>>,
Path(name): Path<String>,
) -> 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<AppState>,
@@ -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<Extension<AuthIdentity>>, 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
+66
View File
@@ -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<PathBuf>,
/// 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(),
}
}
}
+44 -3
View File
@@ -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.
+556
View File
@@ -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 `<input>.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 `<cache>/<recording stem>.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<Semaphore>,
/// Jobs not yet finished, and the reason for the last failure, by
/// recording name. Absent: nothing in progress (look at the cache).
jobs: Mutex<HashMap<String, Job>>,
}
#[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<u64> {
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<Self>, 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<String> = 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 `<input>.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<Mp4Converter>, 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());
}
}
+86 -1
View File
@@ -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 '<button class="btn-small btn-mp4" data-mp4="' + escapeAttr(name) + '"' +
(st === 'running' ? ' disabled' : '') +
' title="Download as an MP4 video that plays anywhere">' +
(MP4_LABELS[st] || 'mp4') + '</button>';
}
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') +
'<th></th><th></th><th></th>' +
'<th></th><th></th>' + (mp4Available ? '<th></th>' : '') + '<th></th>' +
'</tr></thead><tbody>';
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 @@
'<td>' + date + '</td>' +
'<td><button class="btn-small" data-play="' + escapeAttr(r.name) + '">play</button></td>' +
'<td><a class="btn-small" href="/api/recordings/' + encodeURIComponent(r.name) + '" download="' + escapeAttr(r.name) + '">download</a></td>' +
(mp4Available ? '<td>' + mp4Button(r.name) + '</td>' : '') +
'<td><button class="btn-small btn-delete" data-delete-rec="' + escapeAttr(r.name) + '">delete</button></td>' +
'</tr>';
});
@@ -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) {
+1
View File
@@ -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.
+65
View File
@@ -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);
});