Skip to content

Commit 0512d7b

Browse files
committed
feat(dgw): refuse JREC push when recording storage is full
1 parent 7fe1038 commit 0512d7b

7 files changed

Lines changed: 128 additions & 10 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ Stable options are:
170170

171171
- **RecordingPath** (_FilePath_): Path to the recordings folder.
172172

173+
- **MinRecordingStorageFreeSpace** (_Integer_): Minimum free space (in bytes) on the recording storage volume below which
174+
`/jet/jrec/push/{id}` rejects new recording streams with HTTP 507 Insufficient Storage. Omit to skip this threshold
175+
check; the gateway can still return 507 independently when the recording storage is not writable.
176+
173177
- **Ngrok** (_Object_): JSON object describing the ngrok configuration for ingress listeners.
174178

175179
* **AuthToken** (_String_): Specifies the authentication token used to connect to the ngrok service.

crates/transport/src/ws.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,30 @@ impl CloseWebSocketHandle {
173173
})
174174
.await;
175175
}
176+
177+
/// Sends a close frame with an application-specific code in the private range (4000–4999).
178+
///
179+
/// Clients dispatch on `code`; no reason text is sent. If `code` is outside the private
180+
/// range (e.g. a typo or a reserved code such as 1005/1006 which RFC 6455 §7.4.1 forbids
181+
/// sending on the wire), this falls back to 1011 to keep the protocol valid.
182+
pub async fn app_close(self, code: u16) {
183+
let safe_code = if (4000..=4999).contains(&code) {
184+
code
185+
} else {
186+
tracing::error!(
187+
code,
188+
"app_close called with a code outside the private range; falling back to 1011"
189+
);
190+
1011
191+
};
192+
let _ = self
193+
.sender
194+
.send(WsCloseFrame {
195+
code: safe_code,
196+
message: String::new(),
197+
})
198+
.await;
199+
}
176200
}
177201

178202
/// A background "sentinel" task responsible for keeping the WebSocket connection alive

devolutions-gateway/src/api/jrec.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ use tracing::Instrument as _;
1717
use uuid::Uuid;
1818

1919
use crate::DgwState;
20+
use crate::api::heartbeat::recording_storage_health;
2021
use crate::extract::{JrecToken, RecordingDeleteScope, RecordingsReadScope};
2122
use crate::http::{HttpError, HttpErrorBuilder};
22-
use crate::recording::RecordingMessageSender;
23+
use crate::recording::{PushOutcome, RecordingMessageSender};
2324
use crate::token::{JrecTokenClaims, RecordingFileType, RecordingOperation};
2425

2526
pub fn make_router<S>(state: DgwState) -> Router<S> {
@@ -65,6 +66,32 @@ async fn jrec_push(
6566
return Err(HttpError::forbidden().msg("expected push operation"));
6667
}
6768

69+
let conf = conf_handle.get_conf();
70+
71+
// Pre-flight: refuse the upgrade up-front when the recording storage cannot accept
72+
// a new stream. Returning HTTP 507 here gives the client a clear, actionable status
73+
// before the WebSocket is even established, so it can avoid a doomed session entirely.
74+
let storage = recording_storage_health(conf.recording_path.as_std_path());
75+
if !storage.recording_storage_is_writeable {
76+
warn!(client = %source_addr, %session_id, "Refusing JREC push: recording storage is not writable");
77+
return Err(HttpErrorBuilder::new(StatusCode::INSUFFICIENT_STORAGE).msg("recording storage is not writable"));
78+
}
79+
if let (Some(min), Some(available)) = (
80+
conf.min_recording_storage_free_space,
81+
storage.recording_storage_available_space,
82+
) && available < min
83+
{
84+
warn!(
85+
client = %source_addr,
86+
%session_id,
87+
available_bytes = available,
88+
min_bytes = min,
89+
"Refusing JREC push: free space below configured minimum"
90+
);
91+
return Err(HttpErrorBuilder::new(StatusCode::INSUFFICIENT_STORAGE)
92+
.msg("recording storage below minimum free-space threshold"));
93+
}
94+
6895
let response = ws.on_upgrade(move |ws| {
6996
handle_jrec_push(
7097
ws,
@@ -110,14 +137,26 @@ async fn handle_jrec_push(
110137
.instrument(info_span!("jrec", client = %source_addr))
111138
.await;
112139

113-
if let Err(error) = result {
114-
close_handle.server_error("forwarding failure".to_owned()).await;
115-
error!(client = %source_addr, error = format!("{error:#}"), "WebSocket-JREC failure");
116-
} else {
117-
close_handle.normal_close().await;
140+
match result {
141+
Ok(PushOutcome::Done) => close_handle.normal_close().await,
142+
Ok(PushOutcome::StorageFull) => {
143+
warn!(client = %source_addr, %session_id, "JREC push closed: storage full");
144+
close_handle.app_close(STORAGE_FULL_CLOSE_CODE).await;
145+
}
146+
Err(error) => {
147+
close_handle.server_error("forwarding failure".to_owned()).await;
148+
error!(client = %source_addr, error = format!("{error:#}"), "WebSocket-JREC failure");
149+
}
118150
}
119151
}
120152

153+
/// WebSocket close code sent on `/jrec/push/{id}` when the recording storage volume is full
154+
/// and the stream cannot continue.
155+
///
156+
/// Codes in 4000-4999 are reserved for private application use per
157+
/// <https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code>.
158+
const STORAGE_FULL_CLOSE_CODE: u16 = 4010;
159+
121160
/// Deletes a recording stored on this instance
122161
#[cfg_attr(feature = "openapi", utoipa::path(
123162
delete,

devolutions-gateway/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ pub struct Conf {
188188
pub delegation_private_key: Option<PrivateKey>,
189189
pub plugins: Option<Vec<Utf8PathBuf>>,
190190
pub recording_path: Utf8PathBuf,
191+
pub min_recording_storage_free_space: Option<u64>,
191192
pub jrl_file: Utf8PathBuf,
192193
pub ngrok: Option<dto::NgrokConf>,
193194
pub verbosity_profile: dto::VerbosityProfile,
@@ -911,6 +912,7 @@ impl Conf {
911912
delegation_private_key,
912913
plugins: conf_file.plugins.clone(),
913914
recording_path,
915+
min_recording_storage_free_space: conf_file.min_recording_storage_free_space,
914916
jrl_file,
915917
ngrok: conf_file.ngrok.clone(),
916918
verbosity_profile: conf_file.verbosity_profile.unwrap_or_default(),
@@ -1685,6 +1687,13 @@ pub mod dto {
16851687
#[serde(skip_serializing_if = "Option::is_none")]
16861688
pub recording_path: Option<Utf8PathBuf>,
16871689

1690+
/// Minimum free space (in bytes) on the recording storage volume below which the
1691+
/// gateway returns HTTP 507 Insufficient Storage on `/jet/jrec/push/{id}`. Omit to
1692+
/// skip this threshold check; the gateway can still return 507 independently when
1693+
/// the recording storage is not writable.
1694+
#[serde(skip_serializing_if = "Option::is_none")]
1695+
pub min_recording_storage_free_space: Option<u64>,
1696+
16881697
/// Ngrok config (closely maps https://ngrok.com/docs/ngrok-agent/config/)
16891698
#[serde(skip_serializing_if = "Option::is_none")]
16901699
pub ngrok: Option<NgrokConf>,
@@ -1776,6 +1785,7 @@ pub mod dto {
17761785
jrl_file: None,
17771786
plugins: None,
17781787
recording_path: None,
1788+
min_recording_storage_free_space: None,
17791789
web_app: None,
17801790
ai_gateway: None,
17811791
job_queue_database: None,

devolutions-gateway/src/recording.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ impl JrecManifest {
5858
}
5959
}
6060

61+
/// Outcome of a successful push session.
62+
///
63+
/// `Err` from `ClientPush::run` is reserved for unexpected failures. Conditions that the
64+
/// caller is expected to surface to the client (e.g. disk full) are reported as variants of
65+
/// this enum so the caller can choose an appropriate close frame.
66+
#[derive(Debug)]
67+
pub enum PushOutcome {
68+
/// The recording stream completed normally or was terminated by a shutdown signal.
69+
Done,
70+
/// The underlying file write failed because the recording storage volume is full.
71+
StorageFull,
72+
}
73+
6174
#[derive(TypedBuilder)]
6275
pub struct ClientPush<S> {
6376
recordings: RecordingMessageSender,
@@ -72,7 +85,7 @@ impl<S> ClientPush<S>
7285
where
7386
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
7487
{
75-
pub async fn run(self) -> anyhow::Result<()> {
88+
pub async fn run(self) -> anyhow::Result<PushOutcome> {
7689
let Self {
7790
recordings,
7891
claims,
@@ -98,7 +111,7 @@ where
98111
Err(e) => {
99112
warn!(error = format!("{e:#}"), "Unable to start recording");
100113
client_stream.shutdown().await.context("shutdown")?;
101-
return Ok(());
114+
return Ok(PushOutcome::Done);
102115
}
103116
};
104117

@@ -144,11 +157,18 @@ where
144157

145158
let res = tokio::select! {
146159
res = copy_fut => {
147-
res.context("JREC streaming to file").map(|_| ())
160+
match res {
161+
Ok(_) => Ok(PushOutcome::Done),
162+
Err(e) if is_storage_full(&e) => {
163+
warn!(%session_id, "Recording storage is full; closing push stream");
164+
Ok(PushOutcome::StorageFull)
165+
}
166+
Err(e) => Err(anyhow::Error::new(e).context("JREC streaming to file")),
167+
}
148168
},
149169
_ = shutdown_signal.wait() => {
150170
trace!("Received shutdown signal");
151-
client_stream.shutdown().await.context("shutdown")
171+
client_stream.shutdown().await.context("shutdown").map(|_| PushOutcome::Done)
152172
},
153173
};
154174

@@ -167,6 +187,15 @@ where
167187
}
168188
}
169189

190+
/// Returns `true` if the I/O error indicates the storage volume is full.
191+
///
192+
/// Uses `io::ErrorKind::StorageFull` (stable since Rust 1.83) which the standard library maps
193+
/// from the platform-specific code: `ENOSPC` on Unix, `ERROR_DISK_FULL` / `ERROR_HANDLE_DISK_FULL`
194+
/// on Windows.
195+
fn is_storage_full(error: &io::Error) -> bool {
196+
matches!(error.kind(), io::ErrorKind::StorageFull)
197+
}
198+
170199
/// A set containing IDs of currently active recordings.
171200
///
172201
/// The ID is inserted at the initial recording

devolutions-gateway/src/service.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ impl Tasks {
247247
async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result<Tasks> {
248248
let conf = conf_handle.get_conf();
249249

250+
// Ensure the recording storage directory exists up-front so request handlers and the
251+
// recording manager don't each have to handle missing-directory cases lazily.
252+
tokio::fs::create_dir_all(&conf.recording_path)
253+
.await
254+
.with_context(|| format!("failed to create recording path: {}", conf.recording_path))?;
255+
250256
let mut tasks = Tasks::new();
251257

252258
let token_cache = devolutions_gateway::token::new_token_cache().pipe(Arc::new);

devolutions-gateway/tests/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ fn hub_sample() -> Sample {
9191
jrl_file: None,
9292
plugins: None,
9393
recording_path: None,
94+
min_recording_storage_free_space: None,
9495
job_queue_database: None,
9596
traffic_audit_database: None,
9697
ngrok: None,
@@ -140,6 +141,7 @@ fn legacy_sample() -> Sample {
140141
jrl_file: None,
141142
plugins: None,
142143
recording_path: None,
144+
min_recording_storage_free_space: None,
143145
job_queue_database: None,
144146
traffic_audit_database: None,
145147
ngrok: None,
@@ -188,6 +190,7 @@ fn system_store_sample() -> Sample {
188190
jrl_file: None,
189191
plugins: None,
190192
recording_path: None,
193+
min_recording_storage_free_space: None,
191194
job_queue_database: None,
192195
traffic_audit_database: None,
193196
ngrok: None,
@@ -261,6 +264,7 @@ fn standalone_custom_auth_sample() -> Sample {
261264
jrl_file: None,
262265
plugins: None,
263266
recording_path: None,
267+
min_recording_storage_free_space: None,
264268
job_queue_database: None,
265269
traffic_audit_database: None,
266270
ngrok: None,
@@ -341,6 +345,7 @@ fn standalone_no_auth_sample() -> Sample {
341345
jrl_file: None,
342346
plugins: None,
343347
recording_path: None,
348+
min_recording_storage_free_space: None,
344349
job_queue_database: None,
345350
traffic_audit_database: None,
346351
ngrok: None,
@@ -421,6 +426,7 @@ fn proxy_sample() -> Sample {
421426
jrl_file: None,
422427
plugins: None,
423428
recording_path: None,
429+
min_recording_storage_free_space: None,
424430
job_queue_database: None,
425431
traffic_audit_database: None,
426432
ngrok: None,

0 commit comments

Comments
 (0)