manta_server/server/handlers/
console.rs

1//! WebSocket console handlers (interactive PTY attachments).
2//!
3//! - `WS /api/v1/nodes/{xname}/console`    → [`console_node_ws`] —
4//!   attach to a single node's console.
5//! - `WS /api/v1/sessions/{name}/console`  → [`console_session_ws`] —
6//!   attach to a CFS-session-spawned ephemeral environment.
7//!
8//! Both require Vault (for Kubernetes credentials) and the per-site
9//! `k8s_api_url` — the handlers return `501 Not Implemented` if
10//! either is missing (see [`super::require_vault`] /
11//! [`super::require_k8s_url`]). Idle sessions are reaped after
12//! [`crate::server::ServerState::console_inactivity_timeout`].
13
14use 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
37// ---------------------------------------------------------------------------
38// WS /api/v1/nodes/{xname}/console — Interactive node console
39// ---------------------------------------------------------------------------
40
41pub use manta_shared::types::api::queries::ConsoleQuery;
42
43/// `WS /api/v1/nodes/{xname}/console` — attach an interactive PTY console to a node via WebSocket.
44#[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  // Read what we need from the borrowed infra, authorize the xname, and
62  // clone the backend before the borrow ends.  The clone is cheap
63  // (StaticBackendDispatcher is Arc-shaped); it is moved into the
64  // WebSocket closure so the closure does not need to re-traverse
65  // `state.sites`.
66  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    // Authorization: caller must have group access to this xname.
71    // Without this an authenticated user with any group could open
72    // an interactive PTY on any node in the cluster.
73    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  // Move owned state into the spawned WebSocket task. `state` is not
92  // needed inside the closure — the cloned backend is used instead.
93  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// ---------------------------------------------------------------------------
130// WS /api/v1/sessions/{name}/console — Interactive CFS session console
131// ---------------------------------------------------------------------------
132
133/// `WS /api/v1/sessions/{name}/console` — attach an interactive PTY console to a CFS session pod via WebSocket.
134#[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  // Validate vault/k8s presence, authorize the caller against the
152  // session's target groups, check session liveness, then clone the
153  // backend before the infra borrow ends.
154  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    // Authorization: the caller's accessible groups must overlap the
159    // session's target.groups. validate_console_session does NOT do
160    // this check (only "is image-type and running"), so without this
161    // call any authenticated user could attach to any image session.
162    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  // Move owned state into the spawned WebSocket task. `state` is not
180  // needed inside the closure — the cloned backend is used instead.
181  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/// Minimal abstraction over the WebSocket half of the bridge so the
218/// timeout / message-handling loop can be unit-tested against an
219/// in-process mock channel. Cancel-safety follows from the underlying
220/// `WebSocket::recv` / `WebSocket::send` (both documented cancel-safe)
221/// and from `tokio::sync::mpsc` for the test impl.
222#[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
237/// Bridge a WebSocket connection to a console's stdin/stdout streams.
238///
239/// - Binary and text WS frames are forwarded as raw bytes to console stdin.
240/// - Text frames matching `{"type":"resize","cols":N,"rows":N}` are parsed
241///   and forwarded to `resize`; the backend implementation pushes them
242///   onto the underlying transport's resize channel (k8s exec subprotocol
243///   channel 4). The bytes are not forwarded to stdin.
244/// - Console stdout is forwarded as Binary WS frames.
245/// - Either side closing or erroring terminates the bridge.
246/// - The bridge closes automatically after `inactivity_timeout` of silence
247///   from the client, releasing the Kubernetes pod attachment.
248async 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            // Resize control messages are parsed and forwarded to the
269            // backend's resize channel; everything else goes to stdin.
270            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                // Drop the event on a full channel rather than blocking
282                // the bridge; resize is idempotent state, not a sequence
283                // of deltas.
284                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(_)) => {} // Ping/Pong handled by axum automatically
292          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  //! Tests for [`run_console_bridge`] using an in-process mock socket
314  //! and tokio's paused-time scheduler.
315  //!
316  //! Each test follows the same pattern: spawn the bridge with a
317  //! `MockSocket` and a sink/empty pair for the console streams,
318  //! drive it with `tokio::time::advance` + occasional message sends
319  //! over the test-side handles, then assert on whether the bridge
320  //! handle has resolved (loop exited) or not.
321
322  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  /// Records every byte written through it so a test can assert what
330  /// (if anything) the bridge forwarded to console stdin.
331  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  /// An AsyncRead that never yields. Use as `console_out` in tests
357  /// that don't want the server-side branch of the bridge to fire —
358  /// `tokio::io::empty()` is unsuitable here because it returns EOF on
359  /// the first read, which exits the bridge via the `None` arm.
360  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  /// In-process stand-in for axum's `WebSocket` used only in tests.
373  /// `rx` is driven by the test (simulated client → server frames);
374  /// `tx` is observed by the test (simulated server → client frames).
375  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  /// Wait for either the bridge to finish or `cap` of paused time to
407  /// elapse. Returns `true` if the bridge exited within the budget.
408  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    // Just before the deadline — bridge should still be alive.
437    assert!(
438      !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
439      "bridge exited before the 60s inactivity timeout"
440    );
441    // Cross the deadline — bridge should exit.
442    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    // At t≈59s send a binary frame — that resets the deadline to t+60.
467    tokio::time::sleep(Duration::from_secs(59)).await;
468    in_tx
469      .send(Ok(Message::Binary(b"hi".to_vec().into())))
470      .unwrap();
471    // Yield so the bridge actually processes the message before we
472    // advance time again.
473    tokio::task::yield_now().await;
474
475    // At original-t+90 (post-reset deadline is original-t+119) — still alive.
476    assert!(
477      !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
478      "deadline was not reset by client binary message"
479    );
480    // Now well past the reset deadline (~original-t+125) — should exit.
481    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    // The test exercises three guarantees in one shot:
490    //   - deadline resets on any client text frame (including resize)
491    //   - the resize JSON itself is parsed, never written to stdin
492    //   - parsed cols/rows are forwarded to the backend's resize channel
493    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    // Bridge still alive past the original deadline.
519    assert!(
520      !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
521      "deadline was not reset by resize message"
522    );
523    // The resize JSON must not have been written to stdin.
524    assert!(
525      written.lock().unwrap().is_empty(),
526      "resize text frame was forwarded to console stdin (should be parsed)"
527    );
528    // The parsed size must have been forwarded to the resize channel.
529    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    // Pin the *current* behaviour: server-side data flowing through
566    // the bridge does NOT reset the inactivity deadline. The deadline
567    // tracks CLIENT inactivity (no keystrokes), so an idle user
568    // watching scrolling logs will still time out. If that policy
569    // changes, this test makes the decision explicit.
570    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    // A finite chunk of server bytes followed by Pending forever, so
575    // the bridge forwards real data once but is never broken by EOF.
576    // If the deadline DID reset on server data, the bridge would
577    // stay alive past 60s; we assert the opposite below.
578    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    // Drain whatever the bridge forwards so its `send` doesn't block.
594    tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
595
596    // After ~65s of paused time with no CLIENT traffic, the deadline
597    // (set at t=0) should have fired even though server data was
598    // forwarded early on.
599    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}