Skip to main content

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::server::audit::AuditNote;
32use crate::service;
33
34// ---------------------------------------------------------------------------
35// WS /v2/nodes/{xname}/console — Interactive node console
36// ---------------------------------------------------------------------------
37
38pub use manta_shared::types::api::queries::ConsoleQuery;
39
40/// `WS /v2/nodes/{xname}/console` — attach an interactive PTY console to a node via WebSocket.
41#[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  // 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, &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// ---------------------------------------------------------------------------
118// WS /v2/sessions/{name}/console — Interactive CFS session console
119// ---------------------------------------------------------------------------
120
121/// `WS /v2/sessions/{name}/console` — attach an interactive PTY console to a CFS session pod via WebSocket.
122#[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  // Validate vault/k8s presence, authorize the caller against the
143  // session's target groups, check session liveness, then clone the
144  // backend before the infra borrow ends.
145  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    // Authorization: the caller's accessible groups must overlap the
150    // session's target.groups. validate_console_session does NOT do
151    // this check (only "is image-type and running"), so without this
152    // call any authenticated user could attach to any image session.
153    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  // Move owned state into the spawned WebSocket task. `state` is not
171  // needed inside the closure — the cloned backend is used instead.
172  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/// Minimal abstraction over the WebSocket half of the bridge so the
197/// timeout / message-handling loop can be unit-tested against an
198/// in-process mock channel. Cancel-safety follows from the underlying
199/// `WebSocket::recv` / `WebSocket::send` (both documented cancel-safe)
200/// and from `tokio::sync::mpsc` for the test impl.
201#[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
216/// Bridge a WebSocket connection to a console's stdin/stdout streams.
217///
218/// - Binary and text WS frames are forwarded as raw bytes to console stdin.
219/// - Text frames matching `{"type":"resize","cols":N,"rows":N}` are silently
220///   consumed (dynamic resize is not yet supported by the ConsoleTrait).
221/// - Console stdout is forwarded as Binary WS frames.
222/// - Either side closing or erroring terminates the bridge.
223/// - The bridge closes automatically after `inactivity_timeout` of silence
224///   from the client, releasing the Kubernetes pod attachment.
225async 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            // Consume resize control messages silently; forward everything else.
245            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(_)) => {} // Ping/Pong handled by axum automatically
254          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  //! Tests for [`run_console_bridge`] using an in-process mock socket
276  //! and tokio's paused-time scheduler.
277  //!
278  //! Each test follows the same pattern: spawn the bridge with a
279  //! `MockSocket` and a sink/empty pair for the console streams,
280  //! drive it with `tokio::time::advance` + occasional message sends
281  //! over the test-side handles, then assert on whether the bridge
282  //! handle has resolved (loop exited) or not.
283
284  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  /// Records every byte written through it so a test can assert what
292  /// (if anything) the bridge forwarded to console stdin.
293  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  /// An AsyncRead that never yields. Use as `console_out` in tests
319  /// that don't want the server-side branch of the bridge to fire —
320  /// `tokio::io::empty()` is unsuitable here because it returns EOF on
321  /// the first read, which exits the bridge via the `None` arm.
322  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  /// In-process stand-in for axum's `WebSocket` used only in tests.
335  /// `rx` is driven by the test (simulated client → server frames);
336  /// `tx` is observed by the test (simulated server → client frames).
337  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  /// Wait for either the bridge to finish or `cap` of paused time to
369  /// elapse. Returns `true` if the bridge exited within the budget.
370  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    // Just before the deadline — bridge should still be alive.
397    assert!(
398      !bridge_exited_within(&mut handle, Duration::from_secs(59)).await,
399      "bridge exited before the 60s inactivity timeout"
400    );
401    // Cross the deadline — bridge should exit.
402    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    // At t≈59s send a binary frame — that resets the deadline to t+60.
425    tokio::time::sleep(Duration::from_secs(59)).await;
426    in_tx
427      .send(Ok(Message::Binary(b"hi".to_vec().into())))
428      .unwrap();
429    // Yield so the bridge actually processes the message before we
430    // advance time again.
431    tokio::task::yield_now().await;
432
433    // At original-t+90 (post-reset deadline is original-t+119) — still alive.
434    assert!(
435      !bridge_exited_within(&mut handle, Duration::from_secs(31)).await,
436      "deadline was not reset by client binary message"
437    );
438    // Now well past the reset deadline (~original-t+125) — should exit.
439    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    // Two guarantees:
448    //   - deadline resets on any client text frame (including resize)
449    //   - the resize JSON is silently consumed, never written to stdin
450    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    // Bridge still alive past the original deadline.
474    assert!(
475      !bridge_exited_within(&mut handle, Duration::from_secs(30)).await,
476      "deadline was not reset by resize message"
477    );
478    // The resize JSON must not have been written to stdin.
479    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    // Pin the *current* behaviour: server-side data flowing through
513    // the bridge does NOT reset the inactivity deadline. The deadline
514    // tracks CLIENT inactivity (no keystrokes), so an idle user
515    // watching scrolling logs will still time out. If that policy
516    // changes, this test makes the decision explicit.
517    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    // A finite chunk of server bytes followed by Pending forever, so
522    // the bridge forwards real data once but is never broken by EOF.
523    // If the deadline DID reset on server data, the bridge would
524    // stay alive past 60s; we assert the opposite below.
525    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    // Drain whatever the bridge forwards so its `send` doesn't block.
539    tokio::spawn(async move { while out_rx.recv().await.is_some() {} });
540
541    // After ~65s of paused time with no CLIENT traffic, the deadline
542    // (set at t=0) should have fired even though server data was
543    // forwarded early on.
544    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}