1use 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
67const CAST_VERSION: u32 = 2;
69
70const PER_DIAL_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5);
72
73const HTTP2_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
75
76const ALL_DIAL_ATTEMPTS_TIMEOUT: Duration = Duration::from_secs(30);
79
80pub const UPLOAD_ACK_WINDOW: Duration = Duration::from_secs(30);
84
85const EXPECT_CONTINUE_TIMEOUT: Duration = PER_DIAL_ATTEMPT_TIMEOUT;
93
94const MAX_RESPONSE_HEAD: usize = 8 * 1024;
98
99const MAX_ACK_BUFFER: usize = 64 * 1024;
103
104const CAST_QUEUE_DEPTH: usize = 64;
109
110#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RecordingAttempt {
115 pub recorder: SocketAddr,
117 pub failure_message: String,
119}
120
121#[derive(Debug, thiserror::Error)]
123pub enum RecorderError {
124 #[error("recording: no recorders configured")]
126 NoRecorders,
127 #[error("{0}")]
129 AllFailed(String),
130 #[error("recording: timed out connecting to recorders")]
132 DialBudgetElapsed,
133 #[error("{0}")]
135 Recorder(String),
136 #[error("recording: {0}")]
138 Io(#[from] io::Error),
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
146pub struct CastHeader {
147 pub version: u32,
149 pub width: u16,
151 pub height: u16,
153 pub timestamp: i64,
155 #[serde(skip_serializing_if = "String::is_empty")]
157 pub command: String,
158 #[serde(rename = "srcNode")]
160 pub src_node: String,
161 #[serde(rename = "srcNodeID")]
163 pub src_node_id: String,
164 #[serde(rename = "srcNodeTags", skip_serializing_if = "Vec::is_empty")]
166 pub src_node_tags: Vec<String>,
167 #[serde(rename = "srcNodeUserID", skip_serializing_if = "is_zero")]
169 pub src_node_user_id: i64,
170 #[serde(rename = "srcNodeUser", skip_serializing_if = "String::is_empty")]
172 pub src_node_user: String,
173 pub env: BTreeMap<String, String>,
175 #[serde(rename = "sshUser")]
177 pub ssh_user: String,
178 #[serde(rename = "localUser")]
180 pub local_user: String,
181 #[serde(rename = "connectionID")]
184 pub connection_id: String,
185}
186
187fn is_zero(v: &i64) -> bool {
189 *v == 0
190}
191
192impl CastHeader {
193 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 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
219pub fn cast_output_line(elapsed: Duration, data: &[u8]) -> Vec<u8> {
225 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#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum StartFailure {
237 FailOpen,
239 Reject(String),
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum UploadFailure {
247 FailOpen,
249 Terminate(String),
252}
253
254pub 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
264pub 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
275pub struct TailnetDialer(std::sync::Arc<crate::Device>);
282
283impl TailnetDialer {
284 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
298pub trait RecorderDialer: Send + Sync {
303 type Io: AsyncRead + AsyncWrite + Unpin + Send + 'static;
305
306 fn dial(&self, addr: SocketAddr) -> impl Future<Output = io::Result<Self::Io>> + Send;
308}
309
310struct CastBody {
312 rx: Option<mpsc::Receiver<Bytes>>,
314}
315
316impl CastBody {
317 fn empty() -> Self {
319 Self { rx: None }
320 }
321
322 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#[derive(Debug, Default, serde::Deserialize)]
347struct V2ResponseFrame {
348 #[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 #[serde(default)]
357 error: String,
358}
359
360struct RecorderUpload {
362 recorder: SocketAddr,
364 body: mpsc::Sender<Bytes>,
366 done: oneshot::Receiver<Result<(), String>>,
369}
370
371#[derive(Debug, thiserror::Error)]
373#[error("{message}")]
374pub struct RecordingRejected {
375 pub message: String,
377 #[source]
379 pub cause: RecorderError,
380}
381
382#[derive(Debug)]
384pub struct SessionRecording {
385 start: Instant,
387 terminate_message: Option<String>,
391 body: mpsc::Sender<Bytes>,
393 stopped: bool,
396 terminate: Option<oneshot::Receiver<String>>,
399 _alive: oneshot::Sender<()>,
401 recorder: SocketAddr,
403}
404
405impl SessionRecording {
406 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 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 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 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 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 pub fn recorder(&self) -> SocketAddr {
512 self.recorder
513 }
514
515 pub fn take_terminate(&mut self) -> Option<oneshot::Receiver<String>> {
518 self.terminate.take()
519 }
520
521 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
548fn 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
571async 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 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
630async 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
656async 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
664async 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
697async 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 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 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
741async 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
781fn 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 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
805async 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 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
866async 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 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
919fn 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
932async 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
960fn 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 fn recorder_addr() -> SocketAddr {
985 "192.0.2.10:8080".parse().unwrap()
986 }
987
988 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 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 fn dead_connection() -> DuplexStream {
1026 let (near, far) = tokio::io::duplex(64);
1027 drop(far);
1028 near
1029 }
1030
1031 #[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 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 assert_eq!(v["width"], 0);
1063 assert_eq!(v["height"], 0);
1064 }
1065
1066 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 let mut buf = b"not json at all".to_vec();
1238 assert!(take_ack_frames(&mut buf).is_err());
1239 }
1240
1241 #[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 #[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 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 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 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 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 #[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 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 #[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 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 });
1432 }
1433
1434 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 #[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 #[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 rec.record_output(b"still alive").await.expect("fail-open");
1492 }
1493
1494 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 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 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 #[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 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 #[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}