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::server::audit::AuditNote;
32use crate::service;
33
34pub use manta_shared::types::api::queries::ConsoleQuery;
39
40#[utoipa::path(get, path = "/nodes/{xname}/console", tag = "console",
42 params(("xname" = String, Path, description = "Node xname"), ConsoleQuery, SiteHeader),
43 security(("bearerAuth" = [])),
44 responses(
45 (status = 101, description = "WebSocket upgrade"),
46 (status = 401, description = "Unauthorized", body = ErrorResponse),
47 (status = 500, description = "Internal error", body = ErrorResponse),
48 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
49 )
50)]
51#[tracing::instrument(skip_all, fields(xname = %xname))]
52pub async fn console_node_ws(
53 ctx: RequestCtx,
54 note: AuditNote,
55 Path(xname): Path<String>,
56 Query(q): Query<ConsoleQuery>,
57 ws: WebSocketUpgrade,
58) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
59 note.action("console.node").target("node", &xname);
60
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, &token, &site_name, &xname, q.cols, q.rows, &k8s,
103 )
104 .await
105 {
106 Ok((stdin, stdout)) => {
107 run_console_bridge(socket, stdin, stdout, timeout).await;
108 tracing::info!("WebSocket console closed for node {xname}");
109 }
110 Err(e) => {
111 tracing::error!("Failed to attach to node console {xname}: {e:#}");
112 }
113 }
114 }))
115}
116
117#[utoipa::path(get, path = "/sessions/{name}/console", tag = "console",
123 params(("name" = String, Path, description = "Session name"), ConsoleQuery, SiteHeader),
124 security(("bearerAuth" = [])),
125 responses(
126 (status = 101, description = "WebSocket upgrade"),
127 (status = 401, description = "Unauthorized", body = ErrorResponse),
128 (status = 500, description = "Internal error", body = ErrorResponse),
129 (status = 501, description = "Vault or k8s not configured", body = ErrorResponse),
130 )
131)]
132#[tracing::instrument(skip_all, fields(session = %name))]
133pub async fn console_session_ws(
134 ctx: RequestCtx,
135 note: AuditNote,
136 Path(name): Path<String>,
137 Query(q): Query<ConsoleQuery>,
138 ws: WebSocketUpgrade,
139) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
140 note.action("console.session").target("session", &name);
141
142 let (k8s_api_url, vault_base_url, timeout, backend) = {
146 let infra = ctx.infra();
147 let k = require_k8s_url(infra.k8s_api_url)?.to_string();
148 let v = require_vault(infra.vault_base_url)?.to_string();
149 service::session::validate_session_access(&infra, &ctx.token, &name)
154 .await
155 .map_err(to_handler_error)?;
156 service::session::validate_console_session(&infra, &ctx.token, &name)
157 .await
158 .map_err(to_handler_error)?;
159 let backend = infra.backend_clone();
160 (k, v, ctx.state.console_inactivity_timeout, backend)
161 };
162
163 let k8s = K8sDetails {
164 api_url: k8s_api_url,
165 authentication: K8sAuth::Vault {
166 base_url: vault_base_url,
167 },
168 };
169
170 let RequestCtx {
173 state: _,
174 token,
175 site_name,
176 } = ctx;
177
178 Ok(ws.on_upgrade(move |socket| async move {
179 tracing::info!("WebSocket console opened for session {name}");
180 match service::console::attach_to_session_console(
181 &backend, &token, &site_name, &name, q.cols, q.rows, &k8s,
182 )
183 .await
184 {
185 Ok((stdin, stdout)) => {
186 run_console_bridge(socket, stdin, stdout, timeout).await;
187 tracing::info!("WebSocket console closed for session {name}");
188 }
189 Err(e) => {
190 tracing::error!("Failed to attach to session console {name}: {e:#}");
191 }
192 }
193 }))
194}
195
196#[allow(async_fn_in_trait)]
202trait ConsoleSocket: Send + Unpin {
203 async fn recv(&mut self) -> Option<Result<Message, axum::Error>>;
204 async fn send(&mut self, msg: Message) -> Result<(), axum::Error>;
205}
206
207impl ConsoleSocket for WebSocket {
208 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
209 WebSocket::recv(self).await
210 }
211 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
212 WebSocket::send(self, msg).await
213 }
214}
215
216async fn run_console_bridge<S: ConsoleSocket>(
226 mut socket: S,
227 mut console_in: Box<dyn tokio::io::AsyncWrite + Unpin + Send>,
228 console_out: Box<dyn tokio::io::AsyncRead + Unpin + Send>,
229 inactivity_timeout: std::time::Duration,
230) {
231 let mut out_stream = tokio_util::io::ReaderStream::new(console_out);
232 let mut deadline = tokio::time::Instant::now() + inactivity_timeout;
233
234 loop {
235 tokio::select! {
236 msg = socket.recv() => {
237 match msg {
238 Some(Ok(Message::Binary(data))) => {
239 deadline = tokio::time::Instant::now() + inactivity_timeout;
240 if console_in.write_all(&data).await.is_err() { break; }
241 }
242 Some(Ok(Message::Text(text))) => {
243 deadline = tokio::time::Instant::now() + inactivity_timeout;
244 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
246 && v.get("type").and_then(|t| t.as_str()) == Some("resize")
247 {
248 continue;
249 }
250 if console_in.write_all(text.as_bytes()).await.is_err() { break; }
251 }
252 Some(Ok(Message::Close(_))) | None => break,
253 Some(Ok(_)) => {} Some(Err(_)) => break,
255 }
256 }
257 chunk = out_stream.next() => {
258 match chunk {
259 Some(Ok(data)) => {
260 if socket.send(Message::Binary(data)).await.is_err() { break; }
261 }
262 Some(Err(_)) | None => break,
263 }
264 }
265 () = tokio::time::sleep_until(deadline) => {
266 tracing::warn!("Console session idle for {:?}, closing", inactivity_timeout);
267 break;
268 }
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
285 use std::pin::Pin;
286 use std::sync::{Arc, Mutex};
287 use std::task::{Context, Poll};
288 use std::time::Duration;
289 use tokio::sync::mpsc;
290
291 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
294
295 impl tokio::io::AsyncWrite for CaptureWriter {
296 fn poll_write(
297 self: Pin<&mut Self>,
298 _: &mut Context<'_>,
299 buf: &[u8],
300 ) -> Poll<std::io::Result<usize>> {
301 self.0.lock().unwrap().extend_from_slice(buf);
302 Poll::Ready(Ok(buf.len()))
303 }
304 fn poll_flush(
305 self: Pin<&mut Self>,
306 _: &mut Context<'_>,
307 ) -> Poll<std::io::Result<()>> {
308 Poll::Ready(Ok(()))
309 }
310 fn poll_shutdown(
311 self: Pin<&mut Self>,
312 _: &mut Context<'_>,
313 ) -> Poll<std::io::Result<()>> {
314 Poll::Ready(Ok(()))
315 }
316 }
317
318 struct PendingReader;
323
324 impl tokio::io::AsyncRead for PendingReader {
325 fn poll_read(
326 self: Pin<&mut Self>,
327 _: &mut Context<'_>,
328 _: &mut tokio::io::ReadBuf<'_>,
329 ) -> Poll<std::io::Result<()>> {
330 Poll::Pending
331 }
332 }
333
334 struct MockSocket {
338 rx: mpsc::UnboundedReceiver<Result<Message, axum::Error>>,
339 tx: mpsc::UnboundedSender<Message>,
340 }
341
342 impl ConsoleSocket for MockSocket {
343 async fn recv(&mut self) -> Option<Result<Message, axum::Error>> {
344 self.rx.recv().await
345 }
346 async fn send(&mut self, msg: Message) -> Result<(), axum::Error> {
347 self.tx.send(msg).map_err(axum::Error::new)
348 }
349 }
350
351 fn new_mock_socket() -> (
352 MockSocket,
353 mpsc::UnboundedSender<Result<Message, axum::Error>>,
354 mpsc::UnboundedReceiver<Message>,
355 ) {
356 let (in_tx, in_rx) = mpsc::unbounded_channel();
357 let (out_tx, out_rx) = mpsc::unbounded_channel();
358 (
359 MockSocket {
360 rx: in_rx,
361 tx: out_tx,
362 },
363 in_tx,
364 out_rx,
365 )
366 }
367
368 async fn bridge_exited_within(
371 handle: &mut tokio::task::JoinHandle<()>,
372 cap: Duration,
373 ) -> bool {
374 tokio::select! {
375 _ = handle => true,
376 () = tokio::time::sleep(cap) => false,
377 }
378 }
379
380 #[tokio::test(start_paused = true)]
381 async fn inactivity_timeout_fires_when_no_traffic() {
382 let (socket, _in_tx, _out_rx) = new_mock_socket();
383 let console_in = Box::new(tokio::io::sink());
384 let console_out = Box::new(PendingReader);
385
386 let mut handle = tokio::spawn(async move {
387 run_console_bridge(
388 socket,
389 console_in,
390 console_out,
391 Duration::from_secs(60),
392 )
393 .await;
394 });
395
396 assert!(
398 !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
399 "bridge exited before the 60s inactivity timeout"
400 );
401 assert!(
403 bridge_exited_within(&mut handle, Duration::from_secs(5)).await,
404 "bridge did not exit after the inactivity timeout"
405 );
406 }
407
408 #[tokio::test(start_paused = true)]
409 async fn client_binary_message_resets_deadline() {
410 let (socket, in_tx, _out_rx) = new_mock_socket();
411 let console_in = Box::new(tokio::io::sink());
412 let console_out = Box::new(PendingReader);
413
414 let mut handle = tokio::spawn(async move {
415 run_console_bridge(
416 socket,
417 console_in,
418 console_out,
419 Duration::from_secs(60),
420 )
421 .await;
422 });
423
424 tokio::time::sleep(Duration::from_secs(59)).await;
426 in_tx
427 .send(Ok(Message::Binary(b"hi".to_vec().into())))
428 .unwrap();
429 tokio::task::yield_now().await;
432
433 assert!(
435 !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
436 "deadline was not reset by client binary message"
437 );
438 assert!(
440 bridge_exited_within(&mut handle, Duration::from_secs(35)).await,
441 "bridge did not exit after the reset deadline"
442 );
443 }
444
445 #[tokio::test(start_paused = true)]
446 async fn resize_text_is_dropped_and_resets_deadline() {
447 let (socket, in_tx, _out_rx) = new_mock_socket();
451 let written: Arc<Mutex<Vec<u8>>> = Default::default();
452 let console_in = Box::new(CaptureWriter(written.clone()));
453 let console_out = Box::new(PendingReader);
454
455 let mut handle = tokio::spawn(async move {
456 run_console_bridge(
457 socket,
458 console_in,
459 console_out,
460 Duration::from_secs(60),
461 )
462 .await;
463 });
464
465 tokio::time::sleep(Duration::from_secs(59)).await;
466 in_tx
467 .send(Ok(Message::Text(
468 r#"{"type":"resize","cols":120,"rows":40}"#.into(),
469 )))
470 .unwrap();
471 tokio::task::yield_now().await;
472
473 assert!(
475 !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
476 "deadline was not reset by resize message"
477 );
478 assert!(
480 written.lock().unwrap().is_empty(),
481 "resize text frame was forwarded to console stdin (should be silently dropped)"
482 );
483
484 handle.abort();
485 }
486
487 #[tokio::test(start_paused = true)]
488 async fn client_close_exits_loop_immediately() {
489 let (socket, in_tx, _out_rx) = new_mock_socket();
490 let console_in = Box::new(tokio::io::sink());
491 let console_out = Box::new(PendingReader);
492
493 let mut handle = tokio::spawn(async move {
494 run_console_bridge(
495 socket,
496 console_in,
497 console_out,
498 Duration::from_secs(3600),
499 )
500 .await;
501 });
502
503 in_tx.send(Ok(Message::Close(None))).unwrap();
504 assert!(
505 bridge_exited_within(&mut handle, Duration::from_secs(1)).await,
506 "bridge did not exit on Close frame"
507 );
508 }
509
510 #[tokio::test(start_paused = true)]
511 async fn server_to_client_data_does_not_reset_deadline() {
512 use tokio::io::AsyncReadExt;
518
519 let (socket, _in_tx, mut out_rx) = new_mock_socket();
520 let console_in = Box::new(tokio::io::sink());
521 let console_out =
526 Box::new(std::io::Cursor::new(b"chunk".to_vec()).chain(PendingReader));
527
528 let mut handle = tokio::spawn(async move {
529 run_console_bridge(
530 socket,
531 console_in,
532 console_out,
533 Duration::from_secs(60),
534 )
535 .await;
536 });
537
538 tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
540
541 assert!(
545 bridge_exited_within(&mut handle, Duration::from_secs(65)).await,
546 "server-to-client data should NOT keep the deadline alive"
547 );
548 }
549}