1use axum::{
15 Json,
16 extract::{
17 Path, Query,
18 ws::{Message, WebSocket, WebSocketUpgrade},
19 },
20 http::StatusCode,
21 response::IntoResponse,
22};
23use futures::StreamExt;
24use manta_backend_dispatcher::types::{K8sAuth, K8sDetails};
25use tokio::io::AsyncWriteExt;
26
27use super::{
28 ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
29 to_handler_error,
30};
31use crate::service;
32
33pub use manta_shared::types::api::queries::ConsoleQuery;
38
39#[utoipa::path(get, path = "/nodes/{xname}/console", tag = "console",
41 params(("xname" = String, Path, description = "Node xname"), ConsoleQuery, SiteHeader),
42 security(("bearerAuth" = [])),
43 responses(
44 (status = 101, description = "WebSocket upgrade"),
45 (status = 401, description = "Unauthorized", body = ErrorResponse),
46 (status = 500, description = "Internal error", body = ErrorResponse),
47 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
48 )
49)]
50#[tracing::instrument(skip_all, fields(xname = %xname))]
51pub async fn console_node_ws(
52 ctx: RequestCtx,
53 Path(xname): Path<String>,
54 Query(q): Query<ConsoleQuery>,
55 ws: WebSocketUpgrade,
56) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
57 let (k8s_api_url, vault_base_url, timeout, backend) = {
63 let infra = ctx.infra();
64 let k = require_k8s_url(infra.k8s_api_url)?.to_string();
65 let v = require_vault(infra.vault_base_url)?.to_string();
66 service::authorization::validate_user_group_members_access(
70 &infra,
71 &ctx.token,
72 std::slice::from_ref(&xname),
73 )
74 .await
75 .map_err(to_handler_error)?;
76 let backend = infra.backend_clone();
77 (k, v, ctx.state.console_inactivity_timeout, backend)
78 };
79
80 let k8s = K8sDetails {
81 api_url: k8s_api_url,
82 authentication: K8sAuth::Vault {
83 base_url: vault_base_url,
84 },
85 };
86
87 let RequestCtx {
90 state: _,
91 token,
92 site_name,
93 } = ctx;
94
95 Ok(ws.on_upgrade(move |socket| async move {
96 tracing::info!("WebSocket console opened for node {xname}");
97 match service::console::attach_to_node_console(
98 &backend, &token, &site_name, &xname, q.cols, q.rows, &k8s,
99 )
100 .await
101 {
102 Ok((stdin, stdout)) => {
103 run_console_bridge(socket, stdin, stdout, timeout).await;
104 tracing::info!("WebSocket console closed for node {xname}");
105 }
106 Err(e) => {
107 tracing::error!("Failed to attach to node console {xname}: {e:#}");
108 }
109 }
110 }))
111}
112
113#[utoipa::path(get, path = "/sessions/{name}/console", tag = "console",
119 params(("name" = String, Path, description = "Session name"), ConsoleQuery, SiteHeader),
120 security(("bearerAuth" = [])),
121 responses(
122 (status = 101, description = "WebSocket upgrade"),
123 (status = 401, description = "Unauthorized", body = ErrorResponse),
124 (status = 500, description = "Internal error", body = ErrorResponse),
125 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
126 )
127)]
128#[tracing::instrument(skip_all, fields(session = %name))]
129pub async fn console_session_ws(
130 ctx: RequestCtx,
131 Path(name): Path<String>,
132 Query(q): Query<ConsoleQuery>,
133 ws: WebSocketUpgrade,
134) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
135 let (k8s_api_url, vault_base_url, timeout, backend) = {
139 let infra = ctx.infra();
140 let k = require_k8s_url(infra.k8s_api_url)?.to_string();
141 let v = require_vault(infra.vault_base_url)?.to_string();
142 service::session::validate_session_access(&infra, &ctx.token, &name)
147 .await
148 .map_err(to_handler_error)?;
149 service::session::validate_console_session(&infra, &ctx.token, &name)
150 .await
151 .map_err(to_handler_error)?;
152 let backend = infra.backend_clone();
153 (k, v, ctx.state.console_inactivity_timeout, backend)
154 };
155
156 let k8s = K8sDetails {
157 api_url: k8s_api_url,
158 authentication: K8sAuth::Vault {
159 base_url: vault_base_url,
160 },
161 };
162
163 let RequestCtx {
166 state: _,
167 token,
168 site_name,
169 } = ctx;
170
171 Ok(ws.on_upgrade(move |socket| async move {
172 tracing::info!("WebSocket console opened for session {name}");
173 match service::console::attach_to_session_console(
174 &backend, &token, &site_name, &name, q.cols, q.rows, &k8s,
175 )
176 .await
177 {
178 Ok((stdin, stdout)) => {
179 run_console_bridge(socket, stdin, stdout, timeout).await;
180 tracing::info!("WebSocket console closed for session {name}");
181 }
182 Err(e) => {
183 tracing::error!("Failed to attach to session console {name}: {e:#}");
184 }
185 }
186 }))
187}
188
189#[allow(async_fn_in_trait)]
195trait ConsoleSocket: Send + Unpin {
196 async fn recv(&mut self) -> Option<Result<Message, axum::Error>>;
197 async fn send(&mut self, msg: Message) -> Result<(), axum::Error>;
198}
199
200impl ConsoleSocket for WebSocket {
201 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
202 WebSocket::recv(self).await
203 }
204 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
205 WebSocket::send(self, msg).await
206 }
207}
208
209async fn run_console_bridge<S: ConsoleSocket>(
219 mut socket: S,
220 mut console_in: Box<dyn tokio::io::AsyncWrite + Unpin + Send>,
221 console_out: Box<dyn tokio::io::AsyncRead + Unpin + Send>,
222 inactivity_timeout: std::time::Duration,
223) {
224 let mut out_stream = tokio_util::io::ReaderStream::new(console_out);
225 let mut deadline = tokio::time::Instant::now() + inactivity_timeout;
226
227 loop {
228 tokio::select! {
229 msg = socket.recv() => {
230 match msg {
231 Some(Ok(Message::Binary(data))) => {
232 deadline = tokio::time::Instant::now() + inactivity_timeout;
233 if console_in.write_all(&data).await.is_err() { break; }
234 }
235 Some(Ok(Message::Text(text))) => {
236 deadline = tokio::time::Instant::now() + inactivity_timeout;
237 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
239 && v.get("type").and_then(|t| t.as_str()) == Some("resize")
240 {
241 continue;
242 }
243 if console_in.write_all(text.as_bytes()).await.is_err() { break; }
244 }
245 Some(Ok(Message::Close(_))) | None => break,
246 Some(Ok(_)) => {} Some(Err(_)) => break,
248 }
249 }
250 chunk = out_stream.next() => {
251 match chunk {
252 Some(Ok(data)) => {
253 if socket.send(Message::Binary(data)).await.is_err() { break; }
254 }
255 Some(Err(_)) | None => break,
256 }
257 }
258 () = tokio::time::sleep_until(deadline) => {
259 tracing::warn!("Console session idle for {:?}, closing", inactivity_timeout);
260 break;
261 }
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
278 use std::pin::Pin;
279 use std::sync::{Arc, Mutex};
280 use std::task::{Context, Poll};
281 use std::time::Duration;
282 use tokio::sync::mpsc;
283
284 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
287
288 impl tokio::io::AsyncWrite for CaptureWriter {
289 fn poll_write(
290 self: Pin<&mut Self>,
291 _: &mut Context<'_>,
292 buf: &[u8],
293 ) -> Poll<std::io::Result<usize>> {
294 self.0.lock().unwrap().extend_from_slice(buf);
295 Poll::Ready(Ok(buf.len()))
296 }
297 fn poll_flush(
298 self: Pin<&mut Self>,
299 _: &mut Context<'_>,
300 ) -> Poll<std::io::Result<()>> {
301 Poll::Ready(Ok(()))
302 }
303 fn poll_shutdown(
304 self: Pin<&mut Self>,
305 _: &mut Context<'_>,
306 ) -> Poll<std::io::Result<()>> {
307 Poll::Ready(Ok(()))
308 }
309 }
310
311 struct PendingReader;
316
317 impl tokio::io::AsyncRead for PendingReader {
318 fn poll_read(
319 self: Pin<&mut Self>,
320 _: &mut Context<'_>,
321 _: &mut tokio::io::ReadBuf<'_>,
322 ) -> Poll<std::io::Result<()>> {
323 Poll::Pending
324 }
325 }
326
327 struct MockSocket {
331 rx: mpsc::UnboundedReceiver<Result<Message, axum::Error>>,
332 tx: mpsc::UnboundedSender<Message>,
333 }
334
335 impl ConsoleSocket for MockSocket {
336 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
337 self.rx.recv().await
338 }
339 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
340 self.tx.send(msg).map_err(axum::Error::new)
341 }
342 }
343
344 fn new_mock_socket() -> (
345 MockSocket,
346 mpsc::UnboundedSender<Result<Message, axum::Error>>,
347 mpsc::UnboundedReceiver<Message>,
348 ) {
349 let (in_tx, in_rx) = mpsc::unbounded_channel();
350 let (out_tx, out_rx) = mpsc::unbounded_channel();
351 (
352 MockSocket {
353 rx: in_rx,
354 tx: out_tx,
355 },
356 in_tx,
357 out_rx,
358 )
359 }
360
361 async fn bridge_exited_within(
364 handle: &mut tokio::task::JoinHandle<()>,
365 cap: Duration,
366 ) -> bool {
367 tokio::select! {
368 _ = handle => true,
369 () = tokio::time::sleep(cap) => false,
370 }
371 }
372
373 #[tokio::test(start_paused = true)]
374 async fn inactivity_timeout_fires_when_no_traffic() {
375 let (socket, _in_tx, _out_rx) = new_mock_socket();
376 let console_in = Box::new(tokio::io::sink());
377 let console_out = Box::new(PendingReader);
378
379 let mut handle = tokio::spawn(async move {
380 run_console_bridge(
381 socket,
382 console_in,
383 console_out,
384 Duration::from_secs(60),
385 )
386 .await;
387 });
388
389 assert!(
391 !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
392 "bridge exited before the 60s inactivity timeout"
393 );
394 assert!(
396 bridge_exited_within(&mut handle, Duration::from_secs(5)).await,
397 "bridge did not exit after the inactivity timeout"
398 );
399 }
400
401 #[tokio::test(start_paused = true)]
402 async fn client_binary_message_resets_deadline() {
403 let (socket, in_tx, _out_rx) = new_mock_socket();
404 let console_in = Box::new(tokio::io::sink());
405 let console_out = Box::new(PendingReader);
406
407 let mut handle = tokio::spawn(async move {
408 run_console_bridge(
409 socket,
410 console_in,
411 console_out,
412 Duration::from_secs(60),
413 )
414 .await;
415 });
416
417 tokio::time::sleep(Duration::from_secs(59)).await;
419 in_tx
420 .send(Ok(Message::Binary(b"hi".to_vec().into())))
421 .unwrap();
422 tokio::task::yield_now().await;
425
426 assert!(
428 !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
429 "deadline was not reset by client binary message"
430 );
431 assert!(
433 bridge_exited_within(&mut handle, Duration::from_secs(35)).await,
434 "bridge did not exit after the reset deadline"
435 );
436 }
437
438 #[tokio::test(start_paused = true)]
439 async fn resize_text_is_dropped_and_resets_deadline() {
440 let (socket, in_tx, _out_rx) = new_mock_socket();
444 let written: Arc<Mutex<Vec<u8>>> = Default::default();
445 let console_in = Box::new(CaptureWriter(written.clone()));
446 let console_out = Box::new(PendingReader);
447
448 let mut handle = tokio::spawn(async move {
449 run_console_bridge(
450 socket,
451 console_in,
452 console_out,
453 Duration::from_secs(60),
454 )
455 .await;
456 });
457
458 tokio::time::sleep(Duration::from_secs(59)).await;
459 in_tx
460 .send(Ok(Message::Text(
461 r#"{"type":"resize","cols":120,"rows":40}"#.into(),
462 )))
463 .unwrap();
464 tokio::task::yield_now().await;
465
466 assert!(
468 !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
469 "deadline was not reset by resize message"
470 );
471 assert!(
473 written.lock().unwrap().is_empty(),
474 "resize text frame was forwarded to console stdin (should be silently dropped)"
475 );
476
477 handle.abort();
478 }
479
480 #[tokio::test(start_paused = true)]
481 async fn client_close_exits_loop_immediately() {
482 let (socket, in_tx, _out_rx) = new_mock_socket();
483 let console_in = Box::new(tokio::io::sink());
484 let console_out = Box::new(PendingReader);
485
486 let mut handle = tokio::spawn(async move {
487 run_console_bridge(
488 socket,
489 console_in,
490 console_out,
491 Duration::from_secs(3600),
492 )
493 .await;
494 });
495
496 in_tx.send(Ok(Message::Close(None))).unwrap();
497 assert!(
498 bridge_exited_within(&mut handle, Duration::from_secs(1)).await,
499 "bridge did not exit on Close frame"
500 );
501 }
502
503 #[tokio::test(start_paused = true)]
504 async fn server_to_client_data_does_not_reset_deadline() {
505 use tokio::io::AsyncReadExt;
511
512 let (socket, _in_tx, mut out_rx) = new_mock_socket();
513 let console_in = Box::new(tokio::io::sink());
514 let console_out =
519 Box::new(std::io::Cursor::new(b"chunk".to_vec()).chain(PendingReader));
520
521 let mut handle = tokio::spawn(async move {
522 run_console_bridge(
523 socket,
524 console_in,
525 console_out,
526 Duration::from_secs(60),
527 )
528 .await;
529 });
530
531 tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
533
534 assert!(
538 bridge_exited_within(&mut handle, Duration::from_secs(65)).await,
539 "server-to-client data should NOT keep the deadline alive"
540 );
541 }
542}