Skip to main content

tailscale/ssh/
recording.rs

1//! Session-recording transport for Tailscale SSH: stream a PTY session to a tsrecorder in
2//! asciinema **CastV2** format.
3//!
4//! # What this is
5//!
6//! A policy rule may carry `recorders` (a list of tailnet `ip:port` recorder addresses) and an
7//! `onRecordingFailure` action. Go's `tailssh` then *records the session while it runs*: it dials a
8//! recorder, streams an asciinema cast over HTTP, and tees the PTY's output into that stream. This
9//! module is the Rust port of that transport.
10//!
11//! # Upstream
12//!
13//! Ported from `tailscale/tailscale` at commit
14//! `16dacb0c504bef3ca2bacd9478eccaa640e9780d` (`main`, 2026-09-01):
15//!
16//! * `sessionrecording/connect.go` — `ConnectToRecorder`, `supportsV2`, `connectV1`, `connectV2`,
17//!   the `v2ResponseFrame` ack protocol, and the `perDialAttemptTimeout` / `http2ProbeTimeout` /
18//!   `allDialAttemptsTimeout` / `uploadAckWindow` budgets. Unchanged since `v1.102.3`.
19//! * `sessionrecording/header.go` — `CastHeader`. Unchanged since `v1.102.3`.
20//! * `ssh/tailssh/tailssh.go` — `startNewRecording`, `recording`, `loggingWriter` (the fail-open /
21//!   fail-closed policy around a recording that cannot start or cannot be written), and the
22//!   exit-254 refusal code.
23//!
24//! # The two wire protocols
25//!
26//! A recorder is probed for the newer endpoint first, exactly as Go does:
27//!
28//! 1. **V2** — an HTTP/2-over-cleartext (`h2c`) `HEAD /v2/record` probe. A `200` on HTTP/2 means
29//!    the recorder speaks V2, and the cast is uploaded as the body of a `POST /v2/record` whose
30//!    response is a stream of `{"ack":N}` frames. If no ack arrives inside
31//!    [`UPLOAD_ACK_WINDOW`] the upload is considered dead — this is what detects a recorder that
32//!    has silently gone away during an idle session.
33//! 2. **V1** — the legacy `POST /record` over HTTP/1.1, kept for older tsrecorder instances. The
34//!    request announces `Expect: 100-continue` and the recorder's `100 Continue` is the signal
35//!    that it is ready to accept the recording; only then is the session allowed to start.
36//!
37//! The probe is a separate `HEAD` (not an optimistic `POST`) for the reason Go documents: an
38//! HTTP/2 `POST` to an HTTP/1 server hangs until the request body closes instead of answering
39//! `404`, and a recording body stays open for the whole session.
40//!
41//! # Fail-open and fail-closed
42//!
43//! Go's default is **fail-open**: if recording cannot be started the session proceeds unrecorded,
44//! *unless* the policy set `onRecordingFailure.rejectSessionWithMessage`, which makes it
45//! fail-closed. Mid-session, a failed upload terminates the session only when
46//! `onRecordingFailure.terminateSessionWithMessage` is set. Both decisions are
47//! [`start_failure_action`] and [`upload_failure_action`], kept as pure functions so the policy
48//! can be tested without a recorder.
49
50use std::{
51    collections::BTreeMap,
52    io,
53    net::SocketAddr,
54    pin::Pin,
55    task::{Context, Poll},
56    time::{Duration, Instant},
57};
58
59use bytes::Bytes;
60use tokio::{
61    io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader},
62    sync::{mpsc, oneshot},
63};
64use ts_control::SshRecorderFailureAction;
65use ts_http_util::{Client, Method, Request, ResponseExt, StatusCode};
66
67/// asciinema cast format version written in the header (Go `CastHeader.Version = 2`).
68const CAST_VERSION: u32 = 2;
69
70/// Timeout for a single dial of one recorder address (Go `perDialAttemptTimeout`).
71const PER_DIAL_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5);
72
73/// Timeout for the `HEAD /v2/record` probe (Go `http2ProbeTimeout`).
74const HTTP2_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
75
76/// Overall budget for trying every recorder, probes and dials included (Go
77/// `allDialAttemptsTimeout`).
78const ALL_DIAL_ATTEMPTS_TIMEOUT: Duration = Duration::from_secs(30);
79
80/// How long the V2 upload waits for an ack frame before declaring the recorder gone (Go
81/// `uploadAckWindow`). tsrecorder sends acks even with no new data, so this also catches a dead
82/// recorder under an idle session.
83pub const UPLOAD_ACK_WINDOW: Duration = Duration::from_secs(30);
84
85/// How long the V1 connect waits for the recorder's `100 Continue` before giving up on it.
86///
87/// Go has no equivalent bound: `connectV1` selects on the `Got100Continue` trace against the
88/// request's error channel, and a recorder that accepts the TCP connection but never answers the
89/// `Expect:` header leaves `ConnectToRecorder` blocked. Bounding it here turns that hang into an
90/// ordinary per-recorder failure, so the next recorder in the list is still tried and the
91/// policy's `onRecordingFailure` still decides. The value matches the per-dial budget.
92const EXPECT_CONTINUE_TIMEOUT: Duration = PER_DIAL_ATTEMPT_TIMEOUT;
93
94/// Upper bound on a response head (status line plus headers) read from a recorder. A recorder is
95/// a network peer, so its head is read into a bounded buffer rather than until a blank line
96/// arrives — an endless header stream must not grow the client's memory.
97const MAX_RESPONSE_HEAD: usize = 8 * 1024;
98
99/// Upper bound on un-parsed V2 ack-frame bytes buffered from a recorder's response. Ack frames are
100/// tens of bytes; anything past this is a recorder streaming garbage, and the upload is failed
101/// rather than buffered.
102const MAX_ACK_BUFFER: usize = 64 * 1024;
103
104/// Number of cast lines that may be queued for upload before the writer blocks.
105///
106/// Go uses an `io.Pipe`, which blocks the session's writer until the HTTP body reader consumes the
107/// bytes; a small bounded queue is the same back-pressure with one buffered batch of slack.
108const CAST_QUEUE_DEPTH: usize = 64;
109
110/// An attempt to start a recording on one recorder. Mirrors `tailcfg.SSHRecordingAttempt`; the
111/// attempts are in the order the recorders were tried, and on success the last one is the recorder
112/// that accepted the recording.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RecordingAttempt {
115    /// The recorder address this attempt dialed.
116    pub recorder: SocketAddr,
117    /// Why the attempt failed, or empty if it succeeded.
118    pub failure_message: String,
119}
120
121/// Why a recording could not be started on any configured recorder.
122#[derive(Debug, thiserror::Error)]
123pub enum RecorderError {
124    /// The action demanded recording but carried no recorder addresses.
125    #[error("recording: no recorders configured")]
126    NoRecorders,
127    /// Every configured recorder failed; the message enumerates each failure in order.
128    #[error("{0}")]
129    AllFailed(String),
130    /// The overall 30-second budget for trying every recorder elapsed.
131    #[error("recording: timed out connecting to recorders")]
132    DialBudgetElapsed,
133    /// A single recorder failed. Carries the reason.
134    #[error("{0}")]
135    Recorder(String),
136    /// An I/O error talking to a recorder.
137    #[error("recording: {0}")]
138    Io(#[from] io::Error),
139}
140
141/// The header of an asciinema cast file (Go `sessionrecording.CastHeader`).
142///
143/// Only the fields Tailscale SSH sets are modelled; the Kubernetes-proxy fields have no counterpart
144/// in this fork. Field order matches the Go struct so the emitted JSON is byte-comparable.
145#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
146pub struct CastHeader {
147    /// asciinema file format version. Always 2.
148    pub version: u32,
149    /// Terminal width in characters; non-zero for PTY sessions.
150    pub width: u16,
151    /// Terminal height in characters; non-zero for PTY sessions.
152    pub height: u16,
153    /// Unix timestamp of when the recording started.
154    pub timestamp: i64,
155    /// The command that was executed. Empty for shell sessions.
156    #[serde(skip_serializing_if = "String::is_empty")]
157    pub command: String,
158    /// FQDN (MagicDNS name, no trailing dot) of the node originating the connection.
159    #[serde(rename = "srcNode")]
160    pub src_node: String,
161    /// Stable node id of the node originating the connection.
162    #[serde(rename = "srcNodeID")]
163    pub src_node_id: String,
164    /// Tags on the originating node, if it is tagged.
165    #[serde(rename = "srcNodeTags", skip_serializing_if = "Vec::is_empty")]
166    pub src_node_tags: Vec<String>,
167    /// User id of the originating node's owner, if it is not tagged.
168    #[serde(rename = "srcNodeUserID", skip_serializing_if = "is_zero")]
169    pub src_node_user_id: i64,
170    /// Login name of the originating node's owner, if it is not tagged.
171    #[serde(rename = "srcNodeUser", skip_serializing_if = "String::is_empty")]
172    pub src_node_user: String,
173    /// Session environment. Go sets only `TERM`.
174    pub env: BTreeMap<String, String>,
175    /// The username as presented by the client.
176    #[serde(rename = "sshUser")]
177    pub ssh_user: String,
178    /// The effective local username on the server.
179    #[serde(rename = "localUser")]
180    pub local_user: String,
181    /// Identifier of the SSH connection this session belongs to; shared across sessions
182    /// multiplexed on one connection.
183    #[serde(rename = "connectionID")]
184    pub connection_id: String,
185}
186
187/// `serde` predicate for Go's `omitempty` on an integer field.
188fn is_zero(v: &i64) -> bool {
189    *v == 0
190}
191
192impl CastHeader {
193    /// A header for a session starting at `timestamp_unix`, with `TERM` set to `term`.
194    ///
195    /// Go refuses to write an empty `TERM` (`envValFromList` falls back to `xterm-256color`), so an
196    /// empty `term` is normalized the same way here.
197    pub fn new(timestamp_unix: i64, term: &str) -> Self {
198        let term = if term.is_empty() {
199            "xterm-256color"
200        } else {
201            term
202        };
203        Self {
204            version: CAST_VERSION,
205            timestamp: timestamp_unix,
206            env: BTreeMap::from([("TERM".to_string(), term.to_string())]),
207            ..Default::default()
208        }
209    }
210
211    /// The header as the cast file's first line: its JSON encoding plus a newline.
212    pub fn to_line(&self) -> Result<Vec<u8>, serde_json::Error> {
213        let mut line = serde_json::to_vec(self)?;
214        line.push(b'\n');
215        Ok(line)
216    }
217}
218
219/// One CastV2 body frame — `[elapsed_seconds, "o", data]` plus a newline (Go `loggingWriter.Write`).
220///
221/// `data` is the raw PTY bytes. Go stringifies them with `string(p)` and lets `encoding/json`
222/// substitute U+FFFD for invalid UTF-8; [`String::from_utf8_lossy`] is the same substitution, so a
223/// session emitting binary output produces the same cast on both implementations.
224pub fn cast_output_line(elapsed: Duration, data: &[u8]) -> Vec<u8> {
225    // `serde_json` cannot fail on (f64, &str, &str) — a non-finite f64 would be the only failure
226    // mode and `Duration::as_secs_f64` is always finite — so the encoding is unwrapped into the
227    // same "" a failed marshal would leave, never a panic.
228    let frame = (elapsed.as_secs_f64(), "o", String::from_utf8_lossy(data));
229    let mut line = serde_json::to_vec(&frame).unwrap_or_default();
230    line.push(b'\n');
231    line
232}
233
234/// What to do when a recording could **not be started** (Go `startNewRecording`'s error path).
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum StartFailure {
237    /// Proceed with the session unrecorded. Go's default.
238    FailOpen,
239    /// Refuse the session, showing this message to the client
240    /// (`onRecordingFailure.rejectSessionWithMessage`).
241    Reject(String),
242}
243
244/// What to do when an **in-progress** recording upload fails (Go's `errChan` goroutine).
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum UploadFailure {
247    /// Let the session continue unrecorded. Go's default.
248    FailOpen,
249    /// Terminate the session, showing this message to the client
250    /// (`onRecordingFailure.terminateSessionWithMessage`).
251    Terminate(String),
252}
253
254/// Go: `if onFailure != nil && onFailure.RejectSessionWithMessage != ""` → reject, else fail open.
255pub fn start_failure_action(on_failure: Option<&SshRecorderFailureAction>) -> StartFailure {
256    match on_failure {
257        Some(f) if !f.reject_session_with_message.is_empty() => {
258            StartFailure::Reject(f.reject_session_with_message.clone())
259        }
260        _ => StartFailure::FailOpen,
261    }
262}
263
264/// Go: `if onFailure != nil && onFailure.TerminateSessionWithMessage != ""` → terminate, else fail
265/// open.
266pub fn upload_failure_action(on_failure: Option<&SshRecorderFailureAction>) -> UploadFailure {
267    match on_failure {
268        Some(f) if !f.terminate_session_with_message.is_empty() => {
269            UploadFailure::Terminate(f.terminate_session_with_message.clone())
270        }
271        _ => UploadFailure::FailOpen,
272    }
273}
274
275/// The production [`RecorderDialer`]: reaches a recorder as an ordinary tailnet peer.
276///
277/// Go dials recorders with the node's `UserDial`, i.e. over the tailnet and never over the host's
278/// own routing table. [`Device::tcp_connect`][crate::Device::tcp_connect] is the same thing here:
279/// the connection leaves through the overlay, so a recorder address is only reachable if it really
280/// is a tailnet address.
281pub struct TailnetDialer(std::sync::Arc<crate::Device>);
282
283impl TailnetDialer {
284    /// Dial recorders over `dev`'s tailnet.
285    pub fn new(dev: std::sync::Arc<crate::Device>) -> Self {
286        Self(dev)
287    }
288}
289
290impl RecorderDialer for TailnetDialer {
291    type Io = crate::netstack::TcpStream;
292
293    async fn dial(&self, addr: SocketAddr) -> io::Result<Self::Io> {
294        self.0.tcp_connect(addr).await.map_err(io::Error::other)
295    }
296}
297
298/// How a session dials a recorder.
299///
300/// Production dials over the tailnet (Go uses the node's `UserDial`, so a recorder is reached as a
301/// tailnet peer and never over the host's own routing table); tests supply an in-memory pipe.
302pub trait RecorderDialer: Send + Sync {
303    /// The connected stream this dialer produces.
304    type Io: AsyncRead + AsyncWrite + Unpin + Send + 'static;
305
306    /// Dial one recorder address.
307    fn dial(&self, addr: SocketAddr) -> impl Future<Output = io::Result<Self::Io>> + Send;
308}
309
310/// The streaming HTTP request body carrying the cast to the recorder.
311struct CastBody {
312    /// `None` for a body that is empty from the start (the `HEAD` probe).
313    rx: Option<mpsc::Receiver<Bytes>>,
314}
315
316impl CastBody {
317    /// A body that ends immediately.
318    fn empty() -> Self {
319        Self { rx: None }
320    }
321
322    /// A body fed by the session's cast lines.
323    fn channel(rx: mpsc::Receiver<Bytes>) -> Self {
324        Self { rx: Some(rx) }
325    }
326}
327
328impl hyper::body::Body for CastBody {
329    type Data = Bytes;
330    type Error = io::Error;
331
332    fn poll_frame(
333        self: Pin<&mut Self>,
334        cx: &mut Context<'_>,
335    ) -> Poll<Option<Result<hyper::body::Frame<Bytes>, io::Error>>> {
336        match self.get_mut().rx.as_mut() {
337            None => Poll::Ready(None),
338            Some(rx) => rx
339                .poll_recv(cx)
340                .map(|frame| frame.map(|b| Ok(hyper::body::Frame::data(b)))),
341        }
342    }
343}
344
345/// One ack frame of a V2 upload response (Go `v2ResponseFrame`).
346#[derive(Debug, Default, serde::Deserialize)]
347struct V2ResponseFrame {
348    /// Bytes the recorder has received so far. Not a durability guarantee.
349    #[serde(default)]
350    #[allow(
351        dead_code,
352        reason = "the ack's arrival is the signal; its value is advisory"
353    )]
354    ack: i64,
355    /// Set only on the last frame, when the recorder failed to store the recording.
356    #[serde(default)]
357    error: String,
358}
359
360/// A live upload to one recorder: where cast bytes go, and how its end is reported.
361struct RecorderUpload {
362    /// The recorder that accepted the recording.
363    recorder: SocketAddr,
364    /// Sink for cast lines.
365    body: mpsc::Sender<Bytes>,
366    /// Resolves once the upload ends: `Ok(())` on a clean end, `Err(msg)` on failure. Go's
367    /// `errChan`.
368    done: oneshot::Receiver<Result<(), String>>,
369}
370
371/// A recording that could not be started and whose policy says the session must be refused.
372#[derive(Debug, thiserror::Error)]
373#[error("{message}")]
374pub struct RecordingRejected {
375    /// The message to show the connecting client.
376    pub message: String,
377    /// Why recording could not start.
378    #[source]
379    pub cause: RecorderError,
380}
381
382/// A live session recording (Go's `recording` plus its `loggingWriter`).
383#[derive(Debug)]
384pub struct SessionRecording {
385    /// When the recording started; cast frame timestamps are relative to it.
386    start: Instant,
387    /// The message to show the client if the recording breaks, or `None` when the policy fails
388    /// open. Go's `recording.failOpen` is exactly "no `TerminateSessionWithMessage`", so the two
389    /// are the same fact and are kept as one field.
390    terminate_message: Option<String>,
391    /// Sink for cast lines.
392    body: mpsc::Sender<Bytes>,
393    /// Set once a write failed under a fail-open policy; no further cast lines are attempted (Go
394    /// `loggingWriter.recordingFailedOpen`).
395    stopped: bool,
396    /// Fires with the message to show the client when the upload failed and the policy says
397    /// terminate. Taken by the session's output pump.
398    terminate: Option<oneshot::Receiver<String>>,
399    /// Held only so that dropping the recording tells the upload watcher the session is over.
400    _alive: oneshot::Sender<()>,
401    /// The recorder this session streams to, for logging.
402    recorder: SocketAddr,
403}
404
405impl SessionRecording {
406    /// Start recording this session, or decide the session may proceed without a recording.
407    ///
408    /// Port of Go `sshSession.startNewRecording`. The three outcomes are Go's three:
409    ///
410    /// * `Ok(Some(rec))` — a recorder accepted the recording and the header is written.
411    /// * `Ok(None)` — recording could not start and the policy fails **open**; run the session
412    ///   unrecorded (Go's `return nil, nil`).
413    /// * `Err(_)` — the session must be refused (Go's `userVisibleError`, or a header that could
414    ///   not be written — Go exits the session in both cases).
415    pub async fn start<D: RecorderDialer>(
416        recorders: &[SocketAddr],
417        on_failure: Option<&SshRecorderFailureAction>,
418        header: &CastHeader,
419        dialer: &D,
420    ) -> Result<Option<Self>, RecordingRejected> {
421        let (result, attempts) = connect_to_recorder(recorders, dialer).await;
422
423        let upload = match result {
424            Ok(upload) => upload,
425            Err(e) => {
426                notify_unsupported(on_failure, &attempts);
427                return match start_failure_action(on_failure) {
428                    StartFailure::Reject(message) => {
429                        tracing::warn!(error = %e, "recording: error starting recording (rejecting session)");
430                        Err(RecordingRejected { message, cause: e })
431                    }
432                    StartFailure::FailOpen => {
433                        tracing::warn!(error = %e, "recording: error starting recording (failing open)");
434                        Ok(None)
435                    }
436                };
437            }
438        };
439
440        // Go writes the header through the same writer as the body, and treats a failed header
441        // write as a failed start — which refuses the session regardless of `onRecordingFailure`.
442        let line = header.to_line().map_err(|e| RecordingRejected {
443            message: "can't start new recording".to_string(),
444            cause: RecorderError::Recorder(format!("recording: encoding cast header: {e}")),
445        })?;
446        if upload.body.send(Bytes::from(line)).await.is_err() {
447            return Err(RecordingRejected {
448                message: "can't start new recording".to_string(),
449                cause: RecorderError::Recorder(
450                    "recording: recorder closed the upload before the cast header".to_string(),
451                ),
452            });
453        }
454
455        let (terminate_tx, terminate_rx) = oneshot::channel();
456        // Dropped together with the recording when the session ends, which is how the watcher
457        // below tells "the recorder hung up on a live session" from "the session is simply over".
458        let (alive_tx, mut alive_rx) = oneshot::channel::<()>();
459        let action = upload_failure_action(on_failure);
460        let terminate_message = match &action {
461            UploadFailure::Terminate(message) => Some(message.clone()),
462            UploadFailure::FailOpen => None,
463        };
464        let recorder = upload.recorder;
465        let done = upload.done;
466        tokio::spawn(async move {
467            let err = match done.await {
468                // The upload ended cleanly. Go checks the session's context here: if the session
469                // is already over this is just the end of the recording, and only an upload that
470                // ends *while the session runs* is a failure ("recording upload ended before the
471                // SSH session") — the recorder stopped recording a session that is still going.
472                Ok(Ok(())) => {
473                    if matches!(
474                        alive_rx.try_recv(),
475                        Err(oneshot::error::TryRecvError::Closed)
476                    ) {
477                        tracing::debug!(%recorder, "recording: finished uploading recording");
478                        return;
479                    }
480                    "recording upload ended before the SSH session".to_string()
481                }
482                Ok(Err(e)) => e,
483                // The upload task went away without reporting; nothing to act on.
484                Err(_) => return,
485            };
486            match action {
487                UploadFailure::Terminate(message) => {
488                    tracing::warn!(%recorder, error = %err, "recording: error uploading recording (closing session)");
489                    if terminate_tx.send(message).is_err() {
490                        tracing::debug!(%recorder, "recording: session ended before it could be terminated");
491                    }
492                }
493                UploadFailure::FailOpen => {
494                    tracing::warn!(%recorder, error = %err, "recording: error uploading recording (failing open)");
495                }
496            }
497        });
498
499        Ok(Some(Self {
500            start: Instant::now(),
501            terminate_message,
502            body: upload.body,
503            stopped: false,
504            terminate: Some(terminate_rx),
505            _alive: alive_tx,
506            recorder,
507        }))
508    }
509
510    /// The recorder this session is streamed to.
511    pub fn recorder(&self) -> SocketAddr {
512        self.recorder
513    }
514
515    /// Take the channel that fires with the message to show the client when the upload failed and
516    /// the policy says terminate. Yields `None` after the first call.
517    pub fn take_terminate(&mut self) -> Option<oneshot::Receiver<String>> {
518        self.terminate.take()
519    }
520
521    /// Record one chunk of session **output**, then let the caller forward it to the client.
522    ///
523    /// Port of Go `loggingWriter.Write`: the cast line is written first, and a failure to write it
524    /// only stops the session when the policy is fail-closed. Only output is recorded — Go
525    /// deliberately does not record input, which may contain passwords.
526    ///
527    /// `Err` means the session must be terminated (the policy is fail-closed on a broken
528    /// recording); the error is the message to show the client.
529    pub async fn record_output(&mut self, data: &[u8]) -> Result<(), String> {
530        if self.stopped {
531            return Ok(());
532        }
533        let line = cast_output_line(self.start.elapsed(), data);
534        if self.body.send(Bytes::from(line)).await.is_err() {
535            if let Some(message) = &self.terminate_message {
536                return Err(message.clone());
537            }
538            tracing::warn!(
539                recorder = %self.recorder,
540                "recording: recorder upload closed; continuing unrecorded (failing open)"
541            );
542            self.stopped = true;
543        }
544        Ok(())
545    }
546}
547
548/// Log that `onRecordingFailure.notifyURL` cannot be honored.
549///
550/// Go posts an `SSHEventNotifyRequest` to control over Noise (`sshSession.notifyControl`). The
551/// turnkey server here has no control channel of its own, so the notification is reported in the
552/// log instead of silently dropped.
553fn notify_unsupported(
554    on_failure: Option<&SshRecorderFailureAction>,
555    attempts: &[RecordingAttempt],
556) {
557    let Some(url) = on_failure
558        .map(|f| f.notify_url.as_str())
559        .filter(|u| !u.is_empty())
560    else {
561        return;
562    };
563    tracing::warn!(
564        notify_url = %url,
565        attempts = attempts.len(),
566        "recording: onRecordingFailure.notifyURL is set but this server has no control channel to \
567         notify; recording failure is reported here only"
568    );
569}
570
571/// Connect to the first recorder in `recorders` that accepts a recording.
572///
573/// Port of Go `sessionrecording.ConnectToRecorder`. The attempts are returned in the order they
574/// were made whether or not one succeeded; on success the last attempt is the connected recorder.
575async fn connect_to_recorder<D: RecorderDialer>(
576    recorders: &[SocketAddr],
577    dialer: &D,
578) -> (Result<RecorderUpload, RecorderError>, Vec<RecordingAttempt>) {
579    if recorders.is_empty() {
580        return (Err(RecorderError::NoRecorders), Vec::new());
581    }
582
583    // One budget for every probe and dial, so a list of black-holed recorders cannot hold the
584    // session open indefinitely.
585    let deadline = Instant::now() + ALL_DIAL_ATTEMPTS_TIMEOUT;
586
587    let mut attempts = Vec::with_capacity(recorders.len());
588    let mut failures = Vec::new();
589
590    for &addr in recorders {
591        let Some(budget) = deadline.checked_duration_since(Instant::now()) else {
592            attempts.push(RecordingAttempt {
593                recorder: addr,
594                failure_message: RecorderError::DialBudgetElapsed.to_string(),
595            });
596            failures.push(RecorderError::DialBudgetElapsed.to_string());
597            break;
598        };
599
600        match tokio::time::timeout(budget, connect_one(addr, dialer)).await {
601            Ok(Ok(upload)) => {
602                attempts.push(RecordingAttempt {
603                    recorder: addr,
604                    failure_message: String::new(),
605                });
606                return (Ok(upload), attempts);
607            }
608            Ok(Err(e)) => {
609                let msg = format!("recording: error starting recording on {addr}: {e}");
610                attempts.push(RecordingAttempt {
611                    recorder: addr,
612                    failure_message: msg.clone(),
613                });
614                failures.push(msg);
615            }
616            Err(_) => {
617                let msg = format!("recording: error starting recording on {addr}: timed out");
618                attempts.push(RecordingAttempt {
619                    recorder: addr,
620                    failure_message: msg.clone(),
621                });
622                failures.push(msg);
623            }
624        }
625    }
626
627    (Err(RecorderError::AllFailed(failures.join("; "))), attempts)
628}
629
630/// Probe one recorder for V2 and connect over whichever protocol it speaks.
631///
632/// Go probes with a `HEAD` on an `h2c` client and, when the probe fails, connects with a separate
633/// HTTP/1 client — two clients, so two connections. The same split is made here: the probe and a
634/// successful V2 upload share one connection, and the V1 fallback dials a fresh one. A **failed
635/// V2 POST does not fall back**, matching Go: only the probe decides the protocol.
636async fn connect_one<D: RecorderDialer>(
637    addr: SocketAddr,
638    dialer: &D,
639) -> Result<RecorderUpload, RecorderError> {
640    let io = dial(addr, dialer).await?;
641
642    let v2 = match ts_http_util::http2::connect::<CastBody>(io).await {
643        Ok(client) => supports_v2(&client, addr).await.then_some(client),
644        Err(e) => {
645            tracing::debug!(%addr, error = %e, "recording: h2c handshake failed; trying V1");
646            None
647        }
648    };
649
650    match v2 {
651        Some(client) => connect_v2(client, addr).await,
652        None => connect_v1(dial(addr, dialer).await?, addr).await,
653    }
654}
655
656/// Dial one recorder within the per-attempt budget.
657async fn dial<D: RecorderDialer>(addr: SocketAddr, dialer: &D) -> Result<D::Io, RecorderError> {
658    match tokio::time::timeout(PER_DIAL_ATTEMPT_TIMEOUT, dialer.dial(addr)).await {
659        Ok(io) => Ok(io?),
660        Err(_) => Err(RecorderError::Recorder(format!("dialing {addr} timed out"))),
661    }
662}
663
664/// Whether this recorder serves `/v2/record` (Go `supportsV2`).
665///
666/// A `HEAD` is used rather than the `POST` itself because an HTTP/2 `POST` to an HTTP/1 server
667/// hangs until the request body is closed instead of answering `404`, and a recording body is open
668/// for the whole session.
669async fn supports_v2(client: &ts_http_util::Http2<CastBody>, addr: SocketAddr) -> bool {
670    let req = match Request::builder()
671        .method(Method::HEAD)
672        .uri(format!("http://{addr}/v2/record"))
673        .body(CastBody::empty())
674    {
675        Ok(req) => req,
676        Err(e) => {
677            tracing::debug!(%addr, error = %e, "recording: building V2 probe");
678            return false;
679        }
680    };
681
682    match tokio::time::timeout(HTTP2_PROBE_TIMEOUT, client.send(req)).await {
683        Ok(Ok(resp)) => {
684            resp.status() == StatusCode::OK && resp.version() >= hyper::http::Version::HTTP_2
685        }
686        Ok(Err(e)) => {
687            tracing::debug!(%addr, error = %e, "recording: V2 probe failed; falling back to V1");
688            false
689        }
690        Err(_) => {
691            tracing::debug!(%addr, "recording: V2 probe timed out; falling back to V1");
692            false
693        }
694    }
695}
696
697/// Upload the recording to `POST /v2/record` over `h2c` (Go `connectV2`).
698async fn connect_v2(
699    client: ts_http_util::Http2<CastBody>,
700    addr: SocketAddr,
701) -> Result<RecorderUpload, RecorderError> {
702    let (body_tx, body_rx) = mpsc::channel(CAST_QUEUE_DEPTH);
703
704    let req = Request::builder()
705        .method(Method::POST)
706        .uri(format!("http://{addr}/v2/record"))
707        .body(CastBody::channel(body_rx))
708        .map_err(|e| RecorderError::Recorder(format!("building V2 request: {e}")))?;
709
710    // Over HTTP/2 this returns as soon as the response head arrives, so the ack stream can be
711    // consumed while the request body is still being written.
712    let resp = client
713        .send(req)
714        .await
715        .map_err(|e| RecorderError::Recorder(format!("V2 upload: {e}")))?;
716
717    if resp.status() != StatusCode::OK {
718        return Err(RecorderError::Recorder(format!(
719            "recording: unexpected status: {}",
720            resp.status()
721        )));
722    }
723
724    let (done_tx, done_rx) = oneshot::channel();
725    let mut acks = resp.into_read();
726    tokio::spawn(async move {
727        // Hold the client for the upload's lifetime: dropping it tears down the h2 connection.
728        let _client = client;
729        if done_tx.send(read_acks(&mut acks).await).is_err() {
730            tracing::debug!(%addr, "recording: session ended before the upload result was read");
731        }
732    });
733
734    Ok(RecorderUpload {
735        recorder: addr,
736        body: body_tx,
737        done: done_rx,
738    })
739}
740
741/// Consume the recorder's ack stream until it ends, errors, or goes quiet.
742///
743/// Go runs the decode loop and the ack watchdog as two goroutines; bounding each read by
744/// [`UPLOAD_ACK_WINDOW`] is the same rule — tsrecorder acks even when the session is idle, so a
745/// window with no frame means the recorder is gone.
746async fn read_acks<R: AsyncRead + Unpin>(acks: &mut R) -> Result<(), String> {
747    let mut buf = Vec::new();
748    let mut chunk = [0u8; 4096];
749    loop {
750        let n = match tokio::time::timeout(UPLOAD_ACK_WINDOW, acks.read(&mut chunk)).await {
751            Ok(Ok(0)) => return Ok(()),
752            Ok(Ok(n)) => n,
753            Ok(Err(e)) => return Err(format!("recording: unexpected error receiving acks: {e}")),
754            Err(_) => {
755                return Err(format!(
756                    "did not receive ack frames from the recorder in {}s",
757                    UPLOAD_ACK_WINDOW.as_secs()
758                ));
759            }
760        };
761        buf.extend_from_slice(&chunk[..n]);
762        match take_ack_frames(&mut buf) {
763            Ok(frames) => {
764                for frame in frames {
765                    if !frame.error.is_empty() {
766                        return Err(format!(
767                            "recording: received error from the recorder: {:?}",
768                            frame.error
769                        ));
770                    }
771                }
772            }
773            Err(e) => return Err(e),
774        }
775        if buf.len() > MAX_ACK_BUFFER {
776            return Err("recording: recorder sent an oversized ack frame".to_string());
777        }
778    }
779}
780
781/// Decode every complete ack frame at the front of `buf` and drain them from it.
782///
783/// The recorder writes frames back to back with no delimiter, exactly as Go's `json.Decoder`
784/// reads them, so a partial trailing frame is left in `buf` for the next read.
785fn take_ack_frames(buf: &mut Vec<u8>) -> Result<Vec<V2ResponseFrame>, String> {
786    let mut frames = Vec::new();
787    let consumed = {
788        let mut stream = serde_json::Deserializer::from_slice(buf).into_iter::<V2ResponseFrame>();
789        loop {
790            match stream.next() {
791                Some(Ok(frame)) => frames.push(frame),
792                // An incomplete trailing frame is normal: the rest arrives on the next read.
793                Some(Err(e)) if e.is_eof() => break stream.byte_offset(),
794                Some(Err(e)) => {
795                    return Err(format!("recording: unexpected error receiving acks: {e}"));
796                }
797                None => break stream.byte_offset(),
798            }
799        }
800    };
801    buf.drain(..consumed);
802    Ok(frames)
803}
804
805/// Upload the recording to the legacy `POST /record` over HTTP/1.1 (Go `connectV1`).
806///
807/// The request is written by hand rather than through an HTTP client because the recorder's
808/// `100 Continue` **is** the readiness signal: the session is only allowed to start once the
809/// recorder has said it will accept the recording. Go gets that signal from an
810/// `httptrace.ClientTrace`; hyper's client does not surface informational responses, so an
811/// optimistic connect would report success to a recorder that is about to refuse — turning a
812/// fail-closed policy into a silently unrecorded session.
813async fn connect_v1<Io: AsyncRead + AsyncWrite + Unpin + Send + 'static>(
814    io: Io,
815    addr: SocketAddr,
816) -> Result<RecorderUpload, RecorderError> {
817    let (read, mut write) = tokio::io::split(io);
818    let mut read = BufReader::new(read);
819
820    write.write_all(v1_request_head(addr).as_bytes()).await?;
821    write.flush().await?;
822
823    // Wait for `100 Continue`; anything else (including a final status) means this recorder will
824    // not take the recording.
825    let head = match tokio::time::timeout(EXPECT_CONTINUE_TIMEOUT, read_head(&mut read)).await {
826        Ok(head) => head?,
827        Err(_) => {
828            return Err(RecorderError::Recorder(
829                "recording: recorder did not answer Expect: 100-continue".to_string(),
830            ));
831        }
832    };
833    match parse_status(&head) {
834        Some(100) => {}
835        Some(status) => {
836            return Err(RecorderError::Recorder(format!(
837                "recording: unexpected status: {status}"
838            )));
839        }
840        None => {
841            return Err(RecorderError::Recorder(
842                "recording: unparseable response from recorder".to_string(),
843            ));
844        }
845    }
846
847    let (body_tx, mut body_rx) = mpsc::channel::<Bytes>(CAST_QUEUE_DEPTH);
848    let (done_tx, done_rx) = oneshot::channel();
849
850    tokio::spawn(async move {
851        if done_tx
852            .send(pump_v1(&mut body_rx, &mut read, &mut write).await)
853            .is_err()
854        {
855            tracing::debug!(%addr, "recording: session ended before the upload result was read");
856        }
857    });
858
859    Ok(RecorderUpload {
860        recorder: addr,
861        body: body_tx,
862        done: done_rx,
863    })
864}
865
866/// Stream the cast to a V1 recorder as HTTP/1.1 chunked body, then read its final status.
867async fn pump_v1<R, W>(
868    body: &mut mpsc::Receiver<Bytes>,
869    read: &mut BufReader<R>,
870    write: &mut W,
871) -> Result<(), String>
872where
873    R: AsyncRead + Unpin,
874    W: AsyncWrite + Unpin,
875{
876    while let Some(chunk) = body.recv().await {
877        if chunk.is_empty() {
878            // A zero-length chunk is the chunked-encoding terminator; never send one for data.
879            continue;
880        }
881        let framed = format!("{:x}\r\n", chunk.len());
882        write
883            .write_all(framed.as_bytes())
884            .await
885            .map_err(|e| format!("recording: upload write: {e}"))?;
886        write
887            .write_all(&chunk)
888            .await
889            .map_err(|e| format!("recording: upload write: {e}"))?;
890        write
891            .write_all(b"\r\n")
892            .await
893            .map_err(|e| format!("recording: upload write: {e}"))?;
894        write
895            .flush()
896            .await
897            .map_err(|e| format!("recording: upload flush: {e}"))?;
898    }
899
900    write
901        .write_all(b"0\r\n\r\n")
902        .await
903        .map_err(|e| format!("recording: upload close: {e}"))?;
904    write
905        .flush()
906        .await
907        .map_err(|e| format!("recording: upload close: {e}"))?;
908
909    let head = read_head(read)
910        .await
911        .map_err(|e| format!("recording: reading final response: {e}"))?;
912    match parse_status(&head) {
913        Some(200) => Ok(()),
914        Some(status) => Err(format!("recording: unexpected status: {status}")),
915        None => Err("recording: unparseable response from recorder".to_string()),
916    }
917}
918
919/// The `POST /record` request head Go's `net/http` would write for `connectV1`.
920fn v1_request_head(addr: SocketAddr) -> String {
921    format!(
922        "POST /record HTTP/1.1\r\n\
923         Host: {addr}\r\n\
924         User-Agent: tailscale-rs/{version}\r\n\
925         Transfer-Encoding: chunked\r\n\
926         Expect: 100-continue\r\n\
927         \r\n",
928        version = env!("CARGO_PKG_VERSION"),
929    )
930}
931
932/// Read one HTTP response head (status line and headers) up to the blank line.
933///
934/// Bounded by [`MAX_RESPONSE_HEAD`]: the recorder is a network peer, so a head that never ends
935/// must fail rather than grow the buffer.
936async fn read_head<R: AsyncRead + Unpin>(read: &mut BufReader<R>) -> io::Result<String> {
937    let mut head = String::new();
938    loop {
939        let mut line = String::new();
940        let n = read.read_line(&mut line).await?;
941        if n == 0 {
942            return Err(io::Error::new(
943                io::ErrorKind::UnexpectedEof,
944                "recorder closed the connection before answering",
945            ));
946        }
947        head.push_str(&line);
948        if line == "\r\n" || line == "\n" {
949            return Ok(head);
950        }
951        if head.len() > MAX_RESPONSE_HEAD {
952            return Err(io::Error::new(
953                io::ErrorKind::InvalidData,
954                "recorder response head exceeds the maximum size",
955            ));
956        }
957    }
958}
959
960/// The status code of an HTTP response head, or `None` if the status line is not one.
961fn parse_status(head: &str) -> Option<u16> {
962    let line = head.lines().next()?;
963    let mut parts = line.split_whitespace();
964    let version = parts.next()?;
965    if !version.starts_with("HTTP/") {
966        return None;
967    }
968    parts.next()?.parse().ok()
969}
970
971#[cfg(all(test, feature = "ssh"))]
972mod tests {
973    use std::{
974        collections::VecDeque,
975        sync::Mutex as StdMutex,
976        time::{Duration, SystemTime, UNIX_EPOCH},
977    };
978
979    use tokio::io::DuplexStream;
980
981    use super::*;
982
983    /// A recorder address in the RFC 5737 documentation range.
984    fn recorder_addr() -> SocketAddr {
985        "192.0.2.10:8080".parse().unwrap()
986    }
987
988    /// A dialer that hands out pre-arranged connections, in order.
989    ///
990    /// `connect_one` dials once for the V2 probe and, if that probe fails, a second time for the
991    /// V1 fallback — so a V1 test scripts two connections and a V2 test only one.
992    struct ScriptedDialer(StdMutex<VecDeque<DuplexStream>>);
993
994    impl ScriptedDialer {
995        fn new(conns: impl IntoIterator<Item = DuplexStream>) -> Self {
996            Self(StdMutex::new(conns.into_iter().collect()))
997        }
998    }
999
1000    impl RecorderDialer for ScriptedDialer {
1001        type Io = DuplexStream;
1002
1003        async fn dial(&self, _addr: SocketAddr) -> io::Result<Self::Io> {
1004            self.0
1005                .lock()
1006                .expect("scripted dialer lock")
1007                .pop_front()
1008                .ok_or_else(|| io::Error::other("scripted dialer is out of connections"))
1009        }
1010    }
1011
1012    /// A dialer whose every dial fails, standing in for an unreachable recorder.
1013    struct DeadDialer;
1014
1015    impl RecorderDialer for DeadDialer {
1016        type Io = DuplexStream;
1017
1018        async fn dial(&self, _addr: SocketAddr) -> io::Result<Self::Io> {
1019            Err(io::Error::other("no route to recorder"))
1020        }
1021    }
1022
1023    /// A connection whose far end is already gone, so the h2c handshake on it fails immediately
1024    /// and [`connect_one`] falls back to V1.
1025    fn dead_connection() -> DuplexStream {
1026        let (near, far) = tokio::io::duplex(64);
1027        drop(far);
1028        near
1029    }
1030
1031    // ---- cast encoding ----
1032
1033    /// The header is the cast file's first line, and carries Go's field names and values.
1034    #[test]
1035    fn cast_header_line_carries_the_go_field_names() {
1036        let mut header = CastHeader::new(1_700_000_000, "screen-256color");
1037        header.ssh_user = "alice".to_string();
1038        header.local_user = "ubuntu".to_string();
1039        header.src_node = "laptop.tail-scale.ts.net".to_string();
1040        header.src_node_id = "nodeid-abc".to_string();
1041        header.connection_id = "ssh-conn-20231114T221320-0011223344".to_string();
1042        header.src_node_user_id = 42;
1043
1044        let line = header.to_line().expect("header must encode");
1045        assert_eq!(line.last(), Some(&b'\n'), "the header is one cast line");
1046
1047        let v: serde_json::Value = serde_json::from_slice(&line).expect("header must be JSON");
1048        assert_eq!(v["version"], 2);
1049        assert_eq!(v["timestamp"], 1_700_000_000_i64);
1050        assert_eq!(v["env"]["TERM"], "screen-256color");
1051        assert_eq!(v["sshUser"], "alice");
1052        assert_eq!(v["localUser"], "ubuntu");
1053        assert_eq!(v["srcNode"], "laptop.tail-scale.ts.net");
1054        assert_eq!(v["srcNodeID"], "nodeid-abc");
1055        assert_eq!(v["srcNodeUserID"], 42);
1056        assert_eq!(v["connectionID"], "ssh-conn-20231114T221320-0011223344");
1057        // Go marks these `omitempty`, so an unset one must not appear at all.
1058        assert!(v.get("command").is_none(), "empty command must be omitted");
1059        assert!(v.get("srcNodeTags").is_none(), "no tags must be omitted");
1060        assert!(v.get("srcNodeUser").is_none(), "no login must be omitted");
1061        // Width/height are always present, zero for a session with no known PTY size.
1062        assert_eq!(v["width"], 0);
1063        assert_eq!(v["height"], 0);
1064    }
1065
1066    /// An empty `TERM` is normalized the way Go's `envValFromList` fallback does.
1067    #[test]
1068    fn cast_header_defaults_an_empty_term() {
1069        let header = CastHeader::new(0, "");
1070        assert_eq!(
1071            header.env.get("TERM").map(String::as_str),
1072            Some("xterm-256color")
1073        );
1074    }
1075
1076    /// A tagged node records its tags and no owner id, so the two are never both present.
1077    #[test]
1078    fn cast_header_tags_are_omitted_when_absent_and_present_when_set() {
1079        let mut header = CastHeader::new(0, "vt100");
1080        header.src_node_tags = vec!["tag:prod".to_string()];
1081        let v: serde_json::Value =
1082            serde_json::from_slice(&header.to_line().expect("encodes")).expect("JSON");
1083        assert_eq!(v["srcNodeTags"][0], "tag:prod");
1084        assert!(v.get("srcNodeUserID").is_none());
1085    }
1086
1087    /// A body frame is `[elapsed, "o", data]` — output only, one line.
1088    #[test]
1089    fn cast_output_line_is_a_castv2_output_frame() {
1090        let line = cast_output_line(Duration::from_millis(1500), b"hi there");
1091        assert_eq!(line.last(), Some(&b'\n'));
1092        let v: serde_json::Value = serde_json::from_slice(&line).expect("frame must be JSON");
1093        assert_eq!(v[0], 1.5);
1094        assert_eq!(v[1], "o", "only output is recorded, never input");
1095        assert_eq!(v[2], "hi there");
1096    }
1097
1098    /// Binary PTY output is not a JSON error: Go stringifies it and `encoding/json` substitutes
1099    /// U+FFFD, which is what `from_utf8_lossy` does here.
1100    #[test]
1101    fn cast_output_line_survives_invalid_utf8() {
1102        let line = cast_output_line(Duration::ZERO, &[0xff, 0xfe, b'!']);
1103        let v: serde_json::Value = serde_json::from_slice(&line).expect("frame must be JSON");
1104        assert_eq!(v[2], "\u{fffd}\u{fffd}!");
1105    }
1106
1107    // ---- the failure policy ----
1108
1109    /// Go rejects the session only when `RejectSessionWithMessage` is set; everything else fails
1110    /// open.
1111    #[test]
1112    fn start_failure_is_fail_open_unless_reject_message_is_set() {
1113        assert_eq!(start_failure_action(None), StartFailure::FailOpen);
1114        assert_eq!(
1115            start_failure_action(Some(&SshRecorderFailureAction::default())),
1116            StartFailure::FailOpen,
1117            "an empty action must not be read as fail-closed"
1118        );
1119        assert_eq!(
1120            start_failure_action(Some(&SshRecorderFailureAction {
1121                terminate_session_with_message: "gone".to_string(),
1122                ..Default::default()
1123            })),
1124            StartFailure::FailOpen,
1125            "terminate-on-upload-failure says nothing about starting"
1126        );
1127        assert_eq!(
1128            start_failure_action(Some(&SshRecorderFailureAction {
1129                reject_session_with_message: "no recorder, no shell".to_string(),
1130                ..Default::default()
1131            })),
1132            StartFailure::Reject("no recorder, no shell".to_string()),
1133        );
1134    }
1135
1136    /// And terminates a running session only when `TerminateSessionWithMessage` is set.
1137    #[test]
1138    fn upload_failure_is_fail_open_unless_terminate_message_is_set() {
1139        assert_eq!(upload_failure_action(None), UploadFailure::FailOpen);
1140        assert_eq!(
1141            upload_failure_action(Some(&SshRecorderFailureAction {
1142                reject_session_with_message: "no recorder, no shell".to_string(),
1143                ..Default::default()
1144            })),
1145            UploadFailure::FailOpen,
1146            "reject-at-start says nothing about a session already running"
1147        );
1148        assert_eq!(
1149            upload_failure_action(Some(&SshRecorderFailureAction {
1150                terminate_session_with_message: "recording lost".to_string(),
1151                ..Default::default()
1152            })),
1153            UploadFailure::Terminate("recording lost".to_string()),
1154        );
1155    }
1156
1157    /// With no recorder reachable and no explicit reject message, Go proceeds **unrecorded**.
1158    #[tokio::test]
1159    async fn unreachable_recorder_fails_open_by_default() {
1160        let header = CastHeader::new(0, "xterm");
1161        let rec = SessionRecording::start(&[recorder_addr()], None, &header, &DeadDialer)
1162            .await
1163            .expect("the default policy must not refuse the session");
1164        assert!(rec.is_none(), "the session runs, just without a recording");
1165    }
1166
1167    /// With `rejectSessionWithMessage` set, the same failure refuses the session and carries that
1168    /// exact message back to the client.
1169    #[tokio::test]
1170    async fn unreachable_recorder_is_fail_closed_when_the_policy_says_so() {
1171        let on_failure = SshRecorderFailureAction {
1172            reject_session_with_message: "this session must be recorded".to_string(),
1173            ..Default::default()
1174        };
1175        let header = CastHeader::new(0, "xterm");
1176        let err =
1177            SessionRecording::start(&[recorder_addr()], Some(&on_failure), &header, &DeadDialer)
1178                .await
1179                .expect_err("a fail-closed policy must refuse the session");
1180        assert_eq!(err.message, "this session must be recorded");
1181    }
1182
1183    /// Every configured recorder is tried, in order, and each failure is recorded as its own
1184    /// attempt (Go returns the attempts so control can be told which recorders were tried).
1185    #[tokio::test]
1186    async fn every_recorder_is_attempted_in_order() {
1187        let first: SocketAddr = "192.0.2.10:8080".parse().unwrap();
1188        let second: SocketAddr = "198.51.100.20:9000".parse().unwrap();
1189        let (result, attempts) = connect_to_recorder(&[first, second], &DeadDialer).await;
1190        assert!(result.is_err());
1191        assert_eq!(
1192            attempts.iter().map(|a| a.recorder).collect::<Vec<_>>(),
1193            vec![first, second],
1194        );
1195        assert!(attempts.iter().all(|a| !a.failure_message.is_empty()));
1196    }
1197
1198    /// An action that demands recording but names no recorder cannot be honored.
1199    #[tokio::test]
1200    async fn no_recorders_is_an_error_not_a_silent_success() {
1201        let (result, attempts) = connect_to_recorder(&[], &DeadDialer).await;
1202        assert!(matches!(result, Err(RecorderError::NoRecorders)));
1203        assert!(attempts.is_empty());
1204    }
1205
1206    // ---- HTTP plumbing ----
1207
1208    #[test]
1209    fn parse_status_reads_the_status_line() {
1210        assert_eq!(parse_status("HTTP/1.1 100 Continue\r\n\r\n"), Some(100));
1211        assert_eq!(parse_status("HTTP/1.1 200 OK\r\nX: y\r\n\r\n"), Some(200));
1212        assert_eq!(parse_status("HTTP/1.0 404 Not Found\r\n\r\n"), Some(404));
1213        // Anything that is not an HTTP response head is not a status.
1214        assert_eq!(parse_status("hello\r\n"), None);
1215        assert_eq!(parse_status(""), None);
1216        assert_eq!(parse_status("HTTP/1.1 nope\r\n"), None);
1217    }
1218
1219    #[test]
1220    fn ack_frames_are_decoded_and_partials_are_kept() {
1221        // Two whole frames back to back, then half of a third.
1222        let mut buf = br#"{"ack":1}{"ack":2}{"ac"#.to_vec();
1223        let frames = take_ack_frames(&mut buf).expect("two whole frames decode");
1224        assert_eq!(frames.len(), 2);
1225        assert_eq!(
1226            buf,
1227            br#"{"ac"#.to_vec(),
1228            "the partial frame waits for more bytes"
1229        );
1230
1231        // The recorder's error frame is surfaced as the frame's error.
1232        let mut buf = br#"{"error":"disk full"}"#.to_vec();
1233        let frames = take_ack_frames(&mut buf).expect("an error frame is still a frame");
1234        assert_eq!(frames[0].error, "disk full");
1235
1236        // Garbage is a protocol error, not silently skipped.
1237        let mut buf = b"not json at all".to_vec();
1238        assert!(take_ack_frames(&mut buf).is_err());
1239    }
1240
1241    /// A recorder that never ends its response head must not grow the client's memory.
1242    #[tokio::test]
1243    async fn response_head_is_bounded() {
1244        let (near, mut far) = tokio::io::duplex(64 * 1024);
1245        tokio::spawn(async move {
1246            let junk = format!("X-Pad: {}\r\n", "a".repeat(1024));
1247            for _ in 0..32 {
1248                if far.write_all(junk.as_bytes()).await.is_err() {
1249                    return;
1250                }
1251            }
1252        });
1253        let mut read = BufReader::new(near);
1254        let err = read_head(&mut read)
1255            .await
1256            .expect_err("an endless head must fail");
1257        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1258    }
1259
1260    /// The V1 request is what Go's `net/http` would send for `connectV1`.
1261    #[test]
1262    fn v1_request_head_announces_expect_continue() {
1263        let head = v1_request_head(recorder_addr());
1264        assert!(head.starts_with("POST /record HTTP/1.1\r\n"));
1265        assert!(head.contains("Host: 192.0.2.10:8080\r\n"));
1266        assert!(head.contains("Expect: 100-continue\r\n"));
1267        assert!(head.contains("Transfer-Encoding: chunked\r\n"));
1268        assert!(head.ends_with("\r\n\r\n"));
1269    }
1270
1271    // ---- end-to-end against an in-process recorder ----
1272
1273    /// Read one HTTP head from `read`, up to and including the blank line.
1274    async fn read_request_head<R: AsyncRead + Unpin>(read: &mut BufReader<R>) -> String {
1275        let mut head = String::new();
1276        loop {
1277            let mut line = String::new();
1278            let n = read.read_line(&mut line).await.expect("head line");
1279            assert_ne!(n, 0, "connection closed mid-head");
1280            head.push_str(&line);
1281            if line == "\r\n" {
1282                return head;
1283            }
1284        }
1285    }
1286
1287    /// Decode an HTTP/1.1 chunked body up to its terminating zero-length chunk.
1288    async fn read_chunked_body<R: AsyncRead + Unpin>(read: &mut BufReader<R>) -> Vec<u8> {
1289        let mut body = Vec::new();
1290        loop {
1291            let mut size_line = String::new();
1292            if read.read_line(&mut size_line).await.expect("chunk size") == 0 {
1293                return body;
1294            }
1295            let size = usize::from_str_radix(size_line.trim(), 16).expect("chunk size is hex");
1296            if size == 0 {
1297                // Trailer section: a single CRLF for a body with no trailers.
1298                let mut end = String::new();
1299                drop(read.read_line(&mut end).await);
1300                return body;
1301            }
1302            let mut chunk = vec![0u8; size];
1303            read.read_exact(&mut chunk).await.expect("chunk data");
1304            let mut crlf = [0u8; 2];
1305            read.read_exact(&mut crlf).await.expect("chunk CRLF");
1306            body.extend_from_slice(&chunk);
1307        }
1308    }
1309
1310    /// A legacy (V1) tsrecorder: answers `Expect: 100-continue`, takes the chunked cast, and
1311    /// finishes with a 200. Returns the cast it received.
1312    async fn fake_v1_recorder(io: DuplexStream) -> Vec<u8> {
1313        let (read, mut write) = tokio::io::split(io);
1314        let mut read = BufReader::new(read);
1315
1316        let head = read_request_head(&mut read).await;
1317        assert!(
1318            head.starts_with("POST /record HTTP/1.1\r\n"),
1319            "head was {head:?}"
1320        );
1321        assert!(
1322            head.contains("Expect: 100-continue\r\n"),
1323            "head was {head:?}"
1324        );
1325
1326        write
1327            .write_all(b"HTTP/1.1 100 Continue\r\n\r\n")
1328            .await
1329            .expect("100-continue");
1330        write.flush().await.expect("flush");
1331
1332        let cast = read_chunked_body(&mut read).await;
1333
1334        write
1335            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
1336            .await
1337            .expect("final response");
1338        write.flush().await.expect("flush");
1339        cast
1340    }
1341
1342    /// A recorder that speaks only HTTP/1.1 gets the whole cast: header line first, then one
1343    /// output frame per chunk of session output. This exercises the real path — the h2c probe
1344    /// fails, `connect_v1` waits for `100 Continue`, and the session's writes are chunk-framed.
1345    #[tokio::test]
1346    async fn v1_recorder_receives_the_whole_cast() {
1347        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1348        let dialer = ScriptedDialer::new([dead_connection(), client_io]);
1349        let recorder = tokio::spawn(fake_v1_recorder(server_io));
1350
1351        let mut header = CastHeader::new(1_700_000_000, "xterm-256color");
1352        header.local_user = "ubuntu".to_string();
1353
1354        let mut rec = SessionRecording::start(&[recorder_addr()], None, &header, &dialer)
1355            .await
1356            .expect("the recorder accepts the recording")
1357            .expect("recording must be live");
1358        assert_eq!(rec.recorder(), recorder_addr());
1359
1360        rec.record_output(b"$ whoami\r\n").await.expect("recorded");
1361        rec.record_output(b"ubuntu\r\n").await.expect("recorded");
1362        // Ending the session closes the upload, which is what makes the recorder finish.
1363        drop(rec);
1364
1365        let cast = tokio::time::timeout(Duration::from_secs(10), recorder)
1366            .await
1367            .expect("recorder must finish")
1368            .expect("recorder task");
1369        let cast = String::from_utf8(cast).expect("the cast is UTF-8 JSON lines");
1370        let mut lines = cast.lines();
1371
1372        let head: serde_json::Value =
1373            serde_json::from_str(lines.next().expect("header line")).expect("header JSON");
1374        assert_eq!(head["version"], 2);
1375        assert_eq!(head["localUser"], "ubuntu");
1376
1377        let first: serde_json::Value =
1378            serde_json::from_str(lines.next().expect("first frame")).expect("frame JSON");
1379        assert_eq!(first[1], "o");
1380        assert_eq!(first[2], "$ whoami\r\n");
1381
1382        let second: serde_json::Value =
1383            serde_json::from_str(lines.next().expect("second frame")).expect("frame JSON");
1384        assert_eq!(second[2], "ubuntu\r\n");
1385        assert!(lines.next().is_none(), "nothing beyond what was recorded");
1386    }
1387
1388    /// A recorder that answers the `Expect:` with a final status instead of `100 Continue` has
1389    /// refused the recording, and the session must not be treated as recorded.
1390    #[tokio::test]
1391    async fn v1_recorder_that_refuses_is_not_treated_as_connected() {
1392        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1393        tokio::spawn(async move {
1394            let (read, mut write) = tokio::io::split(server_io);
1395            let mut read = BufReader::new(read);
1396            read_request_head(&mut read).await;
1397            drop(
1398                write
1399                    .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
1400                    .await,
1401            );
1402            drop(write.flush().await);
1403        });
1404
1405        let dialer = ScriptedDialer::new([dead_connection(), client_io]);
1406        let on_failure = SshRecorderFailureAction {
1407            reject_session_with_message: "this session must be recorded".to_string(),
1408            ..Default::default()
1409        };
1410        let header = CastHeader::new(0, "xterm");
1411        let err = SessionRecording::start(&[recorder_addr()], Some(&on_failure), &header, &dialer)
1412            .await
1413            .expect_err("a refusing recorder must not look like a live recording");
1414        assert_eq!(err.message, "this session must be recorded");
1415        assert!(
1416            err.cause.to_string().contains("403"),
1417            "the refusal reason must be preserved: {}",
1418            err.cause
1419        );
1420    }
1421
1422    /// A V1 recorder that accepts the recording and then hangs up mid-session.
1423    fn spawn_recorder_that_hangs_up(server_io: DuplexStream) {
1424        tokio::spawn(async move {
1425            let (read, mut write) = tokio::io::split(server_io);
1426            let mut read = BufReader::new(read);
1427            read_request_head(&mut read).await;
1428            drop(write.write_all(b"HTTP/1.1 100 Continue\r\n\r\n").await);
1429            drop(write.flush().await);
1430            // Returning drops both halves, which is the recorder vanishing mid-session.
1431        });
1432    }
1433
1434    /// Keep recording until the broken upload is noticed, and report what `record_output` said.
1435    async fn record_until_it_notices(rec: &mut SessionRecording) -> Result<(), String> {
1436        tokio::time::timeout(Duration::from_secs(10), async {
1437            loop {
1438                rec.record_output(b"x").await?;
1439                tokio::task::yield_now().await;
1440                if rec.stopped {
1441                    return Ok(());
1442                }
1443            }
1444        })
1445        .await
1446        .expect("the broken upload must be noticed")
1447    }
1448
1449    /// A recorder that vanishes mid-session ends the session with the policy's message when
1450    /// `terminateSessionWithMessage` is set — the message the client is owed, not a generic one.
1451    #[tokio::test]
1452    async fn a_broken_upload_is_fail_closed_with_the_policy_message() {
1453        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1454        spawn_recorder_that_hangs_up(server_io);
1455        let dialer = ScriptedDialer::new([dead_connection(), client_io]);
1456
1457        let on_failure = SshRecorderFailureAction {
1458            terminate_session_with_message: "recording lost; ending session".to_string(),
1459            ..Default::default()
1460        };
1461        let header = CastHeader::new(0, "xterm");
1462        let mut rec =
1463            SessionRecording::start(&[recorder_addr()], Some(&on_failure), &header, &dialer)
1464                .await
1465                .expect("the recorder accepted the recording")
1466                .expect("recording must be live");
1467
1468        assert_eq!(
1469            record_until_it_notices(&mut rec).await,
1470            Err("recording lost; ending session".to_string()),
1471        );
1472    }
1473
1474    /// The same recorder vanishing under the default (fail-open) policy leaves the session
1475    /// running: recording stops, the shell does not.
1476    #[tokio::test]
1477    async fn a_broken_upload_is_fail_open_by_default() {
1478        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1479        spawn_recorder_that_hangs_up(server_io);
1480        let dialer = ScriptedDialer::new([dead_connection(), client_io]);
1481
1482        let header = CastHeader::new(0, "xterm");
1483        let mut rec = SessionRecording::start(&[recorder_addr()], None, &header, &dialer)
1484            .await
1485            .expect("the recorder accepted the recording")
1486            .expect("recording must be live");
1487
1488        assert_eq!(record_until_it_notices(&mut rec).await, Ok(()));
1489        assert!(rec.stopped, "no further cast lines are attempted");
1490        // And it stays fail-open: later output is still passed through without error.
1491        rec.record_output(b"still alive").await.expect("fail-open");
1492    }
1493
1494    /// Read a whole `hyper` request body.
1495    async fn collect_incoming(mut body: hyper::body::Incoming) -> Vec<u8> {
1496        use hyper::body::Body as _;
1497        let mut out = Vec::new();
1498        while let Some(frame) = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await
1499        {
1500            match frame {
1501                Ok(frame) => {
1502                    if let Some(data) = frame.data_ref() {
1503                        out.extend_from_slice(data);
1504                    }
1505                }
1506                Err(_) => break,
1507            }
1508        }
1509        out
1510    }
1511
1512    /// A modern (V2) tsrecorder: answers the `HEAD /v2/record` probe, takes the cast as the body
1513    /// of `POST /v2/record`, and acks it. Sends the received cast on `cast_tx`.
1514    async fn fake_v2_recorder(io: DuplexStream, cast_tx: oneshot::Sender<Vec<u8>>) {
1515        let cast_tx = std::sync::Arc::new(StdMutex::new(Some(cast_tx)));
1516        let service = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
1517            let cast_tx = cast_tx.clone();
1518            async move {
1519                let response = |status: StatusCode, body: CastBody| {
1520                    ts_http_util::Response::builder()
1521                        .status(status)
1522                        .body(body)
1523                        .expect("response builds")
1524                };
1525                match (req.method().clone(), req.uri().path()) {
1526                    (Method::HEAD, "/v2/record") => {
1527                        Ok::<_, io::Error>(response(StatusCode::OK, CastBody::empty()))
1528                    }
1529                    (Method::POST, "/v2/record") => {
1530                        let (ack_tx, ack_rx) = mpsc::channel(4);
1531                        tokio::spawn(async move {
1532                            // tsrecorder acks continuously, including while the session is idle.
1533                            drop(ack_tx.send(Bytes::from_static(br#"{"ack":0}"#)).await);
1534                            let cast = collect_incoming(req.into_body()).await;
1535                            drop(
1536                                ack_tx
1537                                    .send(Bytes::from(format!(r#"{{"ack":{}}}"#, cast.len())))
1538                                    .await,
1539                            );
1540                            if let Some(tx) = cast_tx.lock().expect("cast lock").take() {
1541                                drop(tx.send(cast));
1542                            }
1543                        });
1544                        Ok(response(StatusCode::OK, CastBody::channel(ack_rx)))
1545                    }
1546                    _ => Ok(response(StatusCode::NOT_FOUND, CastBody::empty())),
1547                }
1548            }
1549        });
1550
1551        drop(
1552            hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
1553                .serve_connection(hyper_util::rt::TokioIo::new(io), service)
1554                .await,
1555        );
1556    }
1557
1558    /// A recorder that answers the `HEAD /v2/record` probe gets the cast over `h2c` on the same
1559    /// connection — the probe and the upload share one connection, as they do in Go.
1560    #[tokio::test]
1561    async fn v2_recorder_receives_the_whole_cast() {
1562        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1563        let (cast_tx, cast_rx) = oneshot::channel();
1564        tokio::spawn(fake_v2_recorder(server_io, cast_tx));
1565
1566        // Only one connection is scripted: a V2 recorder never falls back to V1.
1567        let dialer = ScriptedDialer::new([client_io]);
1568
1569        let mut header = CastHeader::new(1_700_000_000, "xterm-256color");
1570        header.ssh_user = "alice".to_string();
1571
1572        let mut rec = SessionRecording::start(&[recorder_addr()], None, &header, &dialer)
1573            .await
1574            .expect("the recorder accepts the recording")
1575            .expect("recording must be live");
1576        rec.record_output(b"hello\r\n").await.expect("recorded");
1577        drop(rec);
1578
1579        let cast = tokio::time::timeout(Duration::from_secs(10), cast_rx)
1580            .await
1581            .expect("recorder must finish")
1582            .expect("recorder sends the cast");
1583        let cast = String::from_utf8(cast).expect("the cast is UTF-8 JSON lines");
1584        let mut lines = cast.lines();
1585
1586        let head: serde_json::Value =
1587            serde_json::from_str(lines.next().expect("header line")).expect("header JSON");
1588        assert_eq!(head["sshUser"], "alice");
1589
1590        let frame: serde_json::Value =
1591            serde_json::from_str(lines.next().expect("frame")).expect("frame JSON");
1592        assert_eq!(frame[1], "o");
1593        assert_eq!(frame[2], "hello\r\n");
1594    }
1595
1596    /// The cast header's timestamp is a real Unix timestamp, so a recording is placeable in time.
1597    #[test]
1598    fn cast_header_timestamp_is_unix_seconds() {
1599        let now = SystemTime::now()
1600            .duration_since(UNIX_EPOCH)
1601            .expect("clock after the epoch")
1602            .as_secs() as i64;
1603        let header = CastHeader::new(now, "xterm");
1604        assert!(header.timestamp > 1_600_000_000, "{}", header.timestamp);
1605    }
1606}