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::{
25 interfaces::console::{ConsoleAttachment, TermSize},
26 types::{K8sAuth, K8sDetails},
27};
28use tokio::io::AsyncWriteExt;
29use tokio::sync::mpsc::Sender;
30
31use super::{
32 ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
33 to_handler_error,
34};
35use crate::service;
36
37pub use manta_shared::types::api::queries::ConsoleQuery;
42
43#[utoipa::path(get, path = "/nodes/{xname}/console", tag = "console",
45 params(("xname" = String, Path, description = "Node xname"), ConsoleQuery, SiteHeader),
46 security(("bearerAuth" = [])),
47 responses(
48 (status = 101, description = "WebSocket upgrade"),
49 (status = 401, description = "Unauthorized", body = ErrorResponse),
50 (status = 500, description = "Internal error", body = ErrorResponse),
51 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
52 )
53)]
54#[tracing::instrument(skip_all, fields(xname = %xname))]
55pub async fn console_node_ws(
56 ctx: RequestCtx,
57 Path(xname): Path<String>,
58 Query(q): Query<ConsoleQuery>,
59 ws: WebSocketUpgrade,
60) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
61 let (k8s_api_url, vault_base_url, timeout, backend) = {
67 let infra = ctx.infra();
68 let k = require_k8s_url(infra.k8s_api_url)?.to_string();
69 let v = require_vault(infra.vault_base_url)?.to_string();
70 service::authorization::validate_user_group_members_access(
74 &infra,
75 &ctx.token,
76 std::slice::from_ref(&xname),
77 )
78 .await
79 .map_err(to_handler_error)?;
80 let backend = infra.backend_clone();
81 (k, v, ctx.state.console_inactivity_timeout, backend)
82 };
83
84 let k8s = K8sDetails {
85 api_url: k8s_api_url,
86 authentication: K8sAuth::Vault {
87 base_url: vault_base_url,
88 },
89 };
90
91 let RequestCtx {
94 state: _,
95 token,
96 site_name,
97 } = ctx;
98
99 Ok(ws.on_upgrade(move |socket| async move {
100 tracing::info!("WebSocket console opened for node {xname}");
101 match service::console::attach_to_node_console(
102 &backend,
103 &token,
104 &site_name,
105 &xname,
106 TermSize {
107 width: q.cols,
108 height: q.rows,
109 },
110 &k8s,
111 )
112 .await
113 {
114 Ok(ConsoleAttachment {
115 stdin,
116 stdout,
117 resize,
118 }) => {
119 run_console_bridge(socket, stdin, stdout, resize, timeout).await;
120 tracing::info!("WebSocket console closed for node {xname}");
121 }
122 Err(e) => {
123 tracing::error!("Failed to attach to node console {xname}: {e:#}");
124 }
125 }
126 }))
127}
128
129#[utoipa::path(get, path = "/sessions/{name}/console", tag = "console",
135 params(("name" = String, Path, description = "Session name"), ConsoleQuery, SiteHeader),
136 security(("bearerAuth" = [])),
137 responses(
138 (status = 101, description = "WebSocket upgrade"),
139 (status = 401, description = "Unauthorized", body = ErrorResponse),
140 (status = 500, description = "Internal error", body = ErrorResponse),
141 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
142 )
143)]
144#[tracing::instrument(skip_all, fields(session = %name))]
145pub async fn console_session_ws(
146 ctx: RequestCtx,
147 Path(name): Path<String>,
148 Query(q): Query<ConsoleQuery>,
149 ws: WebSocketUpgrade,
150) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
151 let (k8s_api_url, vault_base_url, timeout, backend) = {
155 let infra = ctx.infra();
156 let k = require_k8s_url(infra.k8s_api_url)?.to_string();
157 let v = require_vault(infra.vault_base_url)?.to_string();
158 service::session::validate_session_access(&infra, &ctx.token, &name)
163 .await
164 .map_err(to_handler_error)?;
165 service::session::validate_console_session(&infra, &ctx.token, &name)
166 .await
167 .map_err(to_handler_error)?;
168 let backend = infra.backend_clone();
169 (k, v, ctx.state.console_inactivity_timeout, backend)
170 };
171
172 let k8s = K8sDetails {
173 api_url: k8s_api_url,
174 authentication: K8sAuth::Vault {
175 base_url: vault_base_url,
176 },
177 };
178
179 let RequestCtx {
182 state: _,
183 token,
184 site_name,
185 } = ctx;
186
187 Ok(ws.on_upgrade(move |socket| async move {
188 tracing::info!("WebSocket console opened for session {name}");
189 match service::console::attach_to_session_console(
190 &backend,
191 &token,
192 &site_name,
193 &name,
194 TermSize {
195 width: q.cols,
196 height: q.rows,
197 },
198 &k8s,
199 )
200 .await
201 {
202 Ok(ConsoleAttachment {
203 stdin,
204 stdout,
205 resize,
206 }) => {
207 run_console_bridge(socket, stdin, stdout, resize, timeout).await;
208 tracing::info!("WebSocket console closed for session {name}");
209 }
210 Err(e) => {
211 tracing::error!("Failed to attach to session console {name}: {e:#}");
212 }
213 }
214 }))
215}
216
217#[allow(async_fn_in_trait)]
223trait ConsoleSocket: Send + Unpin {
224 async fn recv(&mut self) -> Option<Result<Message, axum::Error>>;
225 async fn send(&mut self, msg: Message) -> Result<(), axum::Error>;
226}
227
228impl ConsoleSocket for WebSocket {
229 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
230 WebSocket::recv(self).await
231 }
232 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
233 WebSocket::send(self, msg).await
234 }
235}
236
237async fn run_console_bridge<S: ConsoleSocket>(
249 mut socket: S,
250 mut console_in: Box<dyn tokio::io::AsyncWrite + Unpin + Send>,
251 console_out: Box<dyn tokio::io::AsyncRead + Unpin + Send>,
252 resize: Sender<TermSize>,
253 inactivity_timeout: std::time::Duration,
254) {
255 let mut out_stream = tokio_util::io::ReaderStream::new(console_out);
256 let mut deadline = tokio::time::Instant::now() + inactivity_timeout;
257
258 loop {
259 tokio::select! {
260 msg = socket.recv() => {
261 match msg {
262 Some(Ok(Message::Binary(data))) => {
263 deadline = tokio::time::Instant::now() + inactivity_timeout;
264 if console_in.write_all(&data).await.is_err() { break; }
265 }
266 Some(Ok(Message::Text(text))) => {
267 deadline = tokio::time::Instant::now() + inactivity_timeout;
268 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
271 && v.get("type").and_then(|t| t.as_str()) == Some("resize")
272 {
273 let cols = v.get("cols").and_then(|c| c.as_u64()).unwrap_or(0);
274 let rows = v.get("rows").and_then(|r| r.as_u64()).unwrap_or(0);
275 if cols > 0 && rows > 0 && cols <= u64::from(u16::MAX) && rows <= u64::from(u16::MAX) {
276 #[allow(clippy::cast_possible_truncation)]
277 let size = TermSize {
278 width: cols as u16,
279 height: rows as u16,
280 };
281 let _ = resize.try_send(size);
285 }
286 continue;
287 }
288 if console_in.write_all(text.as_bytes()).await.is_err() { break; }
289 }
290 Some(Ok(Message::Close(_))) | None => break,
291 Some(Ok(_)) => {} Some(Err(_)) => break,
293 }
294 }
295 chunk = out_stream.next() => {
296 match chunk {
297 Some(Ok(data)) => {
298 if socket.send(Message::Binary(data)).await.is_err() { break; }
299 }
300 Some(Err(_)) | None => break,
301 }
302 }
303 () = tokio::time::sleep_until(deadline) => {
304 tracing::warn!("Console session idle for {:?}, closing", inactivity_timeout);
305 break;
306 }
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
323 use std::pin::Pin;
324 use std::sync::{Arc, Mutex};
325 use std::task::{Context, Poll};
326 use std::time::Duration;
327 use tokio::sync::mpsc;
328
329 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
332
333 impl tokio::io::AsyncWrite for CaptureWriter {
334 fn poll_write(
335 self: Pin<&mut Self>,
336 _: &mut Context<'_>,
337 buf: &[u8],
338 ) -> Poll<std::io::Result<usize>> {
339 self.0.lock().unwrap().extend_from_slice(buf);
340 Poll::Ready(Ok(buf.len()))
341 }
342 fn poll_flush(
343 self: Pin<&mut Self>,
344 _: &mut Context<'_>,
345 ) -> Poll<std::io::Result<()>> {
346 Poll::Ready(Ok(()))
347 }
348 fn poll_shutdown(
349 self: Pin<&mut Self>,
350 _: &mut Context<'_>,
351 ) -> Poll<std::io::Result<()>> {
352 Poll::Ready(Ok(()))
353 }
354 }
355
356 struct PendingReader;
361
362 impl tokio::io::AsyncRead for PendingReader {
363 fn poll_read(
364 self: Pin<&mut Self>,
365 _: &mut Context<'_>,
366 _: &mut tokio::io::ReadBuf<'_>,
367 ) -> Poll<std::io::Result<()>> {
368 Poll::Pending
369 }
370 }
371
372 struct MockSocket {
376 rx: mpsc::UnboundedReceiver<Result<Message, axum::Error>>,
377 tx: mpsc::UnboundedSender<Message>,
378 }
379
380 impl ConsoleSocket for MockSocket {
381 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
382 self.rx.recv().await
383 }
384 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
385 self.tx.send(msg).map_err(axum::Error::new)
386 }
387 }
388
389 fn new_mock_socket() -> (
390 MockSocket,
391 mpsc::UnboundedSender<Result<Message, axum::Error>>,
392 mpsc::UnboundedReceiver<Message>,
393 ) {
394 let (in_tx, in_rx) = mpsc::unbounded_channel();
395 let (out_tx, out_rx) = mpsc::unbounded_channel();
396 (
397 MockSocket {
398 rx: in_rx,
399 tx: out_tx,
400 },
401 in_tx,
402 out_rx,
403 )
404 }
405
406 async fn bridge_exited_within(
409 handle: &mut tokio::task::JoinHandle<()>,
410 cap: Duration,
411 ) -> bool {
412 tokio::select! {
413 _ = handle => true,
414 () = tokio::time::sleep(cap) => false,
415 }
416 }
417
418 #[tokio::test(start_paused = true)]
419 async fn inactivity_timeout_fires_when_no_traffic() {
420 let (socket, _in_tx, _out_rx) = new_mock_socket();
421 let console_in = Box::new(tokio::io::sink());
422 let console_out = Box::new(PendingReader);
423 let (resize_tx, _resize_rx) = mpsc::channel(8);
424
425 let mut handle = tokio::spawn(async move {
426 run_console_bridge(
427 socket,
428 console_in,
429 console_out,
430 resize_tx,
431 Duration::from_secs(60),
432 )
433 .await;
434 });
435
436 assert!(
438 !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
439 "bridge exited before the 60s inactivity timeout"
440 );
441 assert!(
443 bridge_exited_within(&mut handle, Duration::from_secs(5)).await,
444 "bridge did not exit after the inactivity timeout"
445 );
446 }
447
448 #[tokio::test(start_paused = true)]
449 async fn client_binary_message_resets_deadline() {
450 let (socket, in_tx, _out_rx) = new_mock_socket();
451 let console_in = Box::new(tokio::io::sink());
452 let console_out = Box::new(PendingReader);
453 let (resize_tx, _resize_rx) = mpsc::channel(8);
454
455 let mut handle = tokio::spawn(async move {
456 run_console_bridge(
457 socket,
458 console_in,
459 console_out,
460 resize_tx,
461 Duration::from_secs(60),
462 )
463 .await;
464 });
465
466 tokio::time::sleep(Duration::from_secs(59)).await;
468 in_tx
469 .send(Ok(Message::Binary(b"hi".to_vec().into())))
470 .unwrap();
471 tokio::task::yield_now().await;
474
475 assert!(
477 !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
478 "deadline was not reset by client binary message"
479 );
480 assert!(
482 bridge_exited_within(&mut handle, Duration::from_secs(35)).await,
483 "bridge did not exit after the reset deadline"
484 );
485 }
486
487 #[tokio::test(start_paused = true)]
488 async fn resize_text_forwards_to_resize_channel_and_resets_deadline() {
489 let (socket, in_tx, _out_rx) = new_mock_socket();
494 let written: Arc<Mutex<Vec<u8>>> = Default::default();
495 let console_in = Box::new(CaptureWriter(written.clone()));
496 let console_out = Box::new(PendingReader);
497 let (resize_tx, mut resize_rx) = mpsc::channel(8);
498
499 let mut handle = tokio::spawn(async move {
500 run_console_bridge(
501 socket,
502 console_in,
503 console_out,
504 resize_tx,
505 Duration::from_secs(60),
506 )
507 .await;
508 });
509
510 tokio::time::sleep(Duration::from_secs(59)).await;
511 in_tx
512 .send(Ok(Message::Text(
513 r#"{"type":"resize","cols":120,"rows":40}"#.into(),
514 )))
515 .unwrap();
516 tokio::task::yield_now().await;
517
518 assert!(
520 !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
521 "deadline was not reset by resize message"
522 );
523 assert!(
525 written.lock().unwrap().is_empty(),
526 "resize text frame was forwarded to console stdin (should be parsed)"
527 );
528 let size = resize_rx.try_recv().expect(
530 "resize message should have been forwarded to the resize channel",
531 );
532 assert_eq!(size.width, 120);
533 assert_eq!(size.height, 40);
534
535 handle.abort();
536 }
537
538 #[tokio::test(start_paused = true)]
539 async fn client_close_exits_loop_immediately() {
540 let (socket, in_tx, _out_rx) = new_mock_socket();
541 let console_in = Box::new(tokio::io::sink());
542 let console_out = Box::new(PendingReader);
543 let (resize_tx, _resize_rx) = mpsc::channel(8);
544
545 let mut handle = tokio::spawn(async move {
546 run_console_bridge(
547 socket,
548 console_in,
549 console_out,
550 resize_tx,
551 Duration::from_secs(3600),
552 )
553 .await;
554 });
555
556 in_tx.send(Ok(Message::Close(None))).unwrap();
557 assert!(
558 bridge_exited_within(&mut handle, Duration::from_secs(1)).await,
559 "bridge did not exit on Close frame"
560 );
561 }
562
563 #[tokio::test(start_paused = true)]
564 async fn server_to_client_data_does_not_reset_deadline() {
565 use tokio::io::AsyncReadExt;
571
572 let (socket, _in_tx, mut out_rx) = new_mock_socket();
573 let console_in = Box::new(tokio::io::sink());
574 let console_out =
579 Box::new(std::io::Cursor::new(b"chunk".to_vec()).chain(PendingReader));
580 let (resize_tx, _resize_rx) = mpsc::channel(8);
581
582 let mut handle = tokio::spawn(async move {
583 run_console_bridge(
584 socket,
585 console_in,
586 console_out,
587 resize_tx,
588 Duration::from_secs(60),
589 )
590 .await;
591 });
592
593 tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
595
596 assert!(
600 bridge_exited_within(&mut handle, Duration::from_secs(65)).await,
601 "server-to-client data should NOT keep the deadline alive"
602 );
603 }
604}