manta_server/server/handlers/
console.rs

1//! WebSocket console handlers (interactive PTY attachments).
2//!
3//! - `WS /v2/nodes/{xname}/console`    → [`console_node_ws`] —
4//!   attach to a single node's console.
5//! - `WS /v2/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::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
33// ---------------------------------------------------------------------------
34// WS /v2/nodes/{xname}/console — Interactive node console
35// ---------------------------------------------------------------------------
36
37pub use manta_shared::types::api::queries::ConsoleQuery;
38
39/// `WS /v2/nodes/{xname}/console` — attach an interactive PTY console to a node via WebSocket.
40#[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  // Read what we need from the borrowed infra, authorize the xname, and
58  // clone the backend before the borrow ends.  The clone is cheap
59  // (StaticBackendDispatcher is Arc-shaped); it is moved into the
60  // WebSocket closure so the closure does not need to re-traverse
61  // `state.sites`.
62  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    // Authorization: caller must have group access to this xname.
67    // Without this an authenticated user with any group could open
68    // an interactive PTY on any node in the cluster.
69    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  // Move owned state into the spawned WebSocket task. `state` is not
88  // needed inside the closure — the cloned backend is used instead.
89  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// ---------------------------------------------------------------------------
114// WS /v2/sessions/{name}/console — Interactive CFS session console
115// ---------------------------------------------------------------------------
116
117/// `WS /v2/sessions/{name}/console` — attach an interactive PTY console to a CFS session pod via WebSocket.
118#[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  // Validate vault/k8s presence, authorize the caller against the
136  // session's target groups, check session liveness, then clone the
137  // backend before the infra borrow ends.
138  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    // Authorization: the caller's accessible groups must overlap the
143    // session's target.groups. validate_console_session does NOT do
144    // this check (only "is image-type and running"), so without this
145    // call any authenticated user could attach to any image session.
146    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  // Move owned state into the spawned WebSocket task. `state` is not
164  // needed inside the closure — the cloned backend is used instead.
165  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/// Minimal abstraction over the WebSocket half of the bridge so the
190/// timeout / message-handling loop can be unit-tested against an
191/// in-process mock channel. Cancel-safety follows from the underlying
192/// `WebSocket::recv` / `WebSocket::send` (both documented cancel-safe)
193/// and from `tokio::sync::mpsc` for the test impl.
194#[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
209/// Bridge a WebSocket connection to a console's stdin/stdout streams.
210///
211/// - Binary and text WS frames are forwarded as raw bytes to console stdin.
212/// - Text frames matching `{"type":"resize","cols":N,"rows":N}` are silently
213///   consumed (dynamic resize is not yet supported by the ConsoleTrait).
214/// - Console stdout is forwarded as Binary WS frames.
215/// - Either side closing or erroring terminates the bridge.
216/// - The bridge closes automatically after `inactivity_timeout` of silence
217///   from the client, releasing the Kubernetes pod attachment.
218async 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            // Consume resize control messages silently; forward everything else.
238            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(_)) => {} // Ping/Pong handled by axum automatically
247          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  //! Tests for [`run_console_bridge`] using an in-process mock socket
269  //! and tokio's paused-time scheduler.
270  //!
271  //! Each test follows the same pattern: spawn the bridge with a
272  //! `MockSocket` and a sink/empty pair for the console streams,
273  //! drive it with `tokio::time::advance` + occasional message sends
274  //! over the test-side handles, then assert on whether the bridge
275  //! handle has resolved (loop exited) or not.
276
277  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  /// Records every byte written through it so a test can assert what
285  /// (if anything) the bridge forwarded to console stdin.
286  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  /// An AsyncRead that never yields. Use as `console_out` in tests
312  /// that don't want the server-side branch of the bridge to fire —
313  /// `tokio::io::empty()` is unsuitable here because it returns EOF on
314  /// the first read, which exits the bridge via the `None` arm.
315  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  /// In-process stand-in for axum's `WebSocket` used only in tests.
328  /// `rx` is driven by the test (simulated client → server frames);
329  /// `tx` is observed by the test (simulated server → client frames).
330  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  /// Wait for either the bridge to finish or `cap` of paused time to
362  /// elapse. Returns `true` if the bridge exited within the budget.
363  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    // Just before the deadline — bridge should still be alive.
390    assert!(
391      !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
392      "bridge exited before the 60s inactivity timeout"
393    );
394    // Cross the deadline — bridge should exit.
395    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    // At t≈59s send a binary frame — that resets the deadline to t+60.
418    tokio::time::sleep(Duration::from_secs(59)).await;
419    in_tx
420      .send(Ok(Message::Binary(b"hi".to_vec().into())))
421      .unwrap();
422    // Yield so the bridge actually processes the message before we
423    // advance time again.
424    tokio::task::yield_now().await;
425
426    // At original-t+90 (post-reset deadline is original-t+119) — still alive.
427    assert!(
428      !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
429      "deadline was not reset by client binary message"
430    );
431    // Now well past the reset deadline (~original-t+125) — should exit.
432    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    // Two guarantees:
441    //   - deadline resets on any client text frame (including resize)
442    //   - the resize JSON is silently consumed, never written to stdin
443    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    // Bridge still alive past the original deadline.
467    assert!(
468      !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
469      "deadline was not reset by resize message"
470    );
471    // The resize JSON must not have been written to stdin.
472    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    // Pin the *current* behaviour: server-side data flowing through
506    // the bridge does NOT reset the inactivity deadline. The deadline
507    // tracks CLIENT inactivity (no keystrokes), so an idle user
508    // watching scrolling logs will still time out. If that policy
509    // changes, this test makes the decision explicit.
510    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    // A finite chunk of server bytes followed by Pending forever, so
515    // the bridge forwards real data once but is never broken by EOF.
516    // If the deadline DID reset on server data, the bridge would
517    // stay alive past 60s; we assert the opposite below.
518    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    // Drain whatever the bridge forwards so its `send` doesn't block.
532    tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
533
534    // After ~65s of paused time with no CLIENT traffic, the deadline
535    // (set at t=0) should have fired even though server data was
536    // forwarded early on.
537    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}