Monorepo for Tangled
tangled.org
1use std::path::Path;
2
3use crate::command::{self, Spec};
4use crate::protocol::{self, Message, v1};
5use anyhow::{Context, Result, bail};
6use nix::sys::signal::{Signal, kill};
7use nix::unistd::{Pid, User};
8use pty_process::Size;
9use tokio::io::{AsyncReadExt, BufReader};
10use tokio_vsock::{VsockAddr, VsockStream};
11use tracing::{info, warn};
12
13const WF_USER: &str = "spindle-workflow";
14const READ_CHUNK: usize = 32 * 1024;
15
16pub async fn run(host_cid: u32, open: v1::OpenDebugShell) {
17 if let Err(error) = serve(host_cid, open).await {
18 warn!(%error, "debug shell session failed");
19 }
20}
21
22async fn serve(host_cid: u32, open: v1::OpenDebugShell) -> Result<()> {
23 let conn = VsockStream::connect(VsockAddr::new(host_cid, open.vsock_port))
24 .await
25 .with_context(|| format!("dial host debug vsock port {}", open.vsock_port))?;
26 info!(port = open.vsock_port, "debug shell connected");
27
28 let user = resolve_user(WF_USER)?;
29
30 let rows = clamp_tty_dim(open.rows);
31 let cols = clamp_tty_dim(open.cols);
32
33 let spec = Spec::new(&user.shell)
34 .arg("-l")
35 .envs(user.env(&open.term))
36 .run_as(user.uid, user.gid)
37 .cwd(Path::new(&user.home).join("repo")); // /workflow/repo
38
39 let (mut pty_reader, mut pty_writer, mut child) =
40 command::spawn_pty(spec, rows, cols).context("spawn pty shell")?;
41 let pid = child.id();
42
43 let (conn_reader, conn_writer) = tokio::io::split(conn);
44 let mut conn_reader = BufReader::new(conn_reader);
45 let mut conn_writer = conn_writer;
46
47 let mut buf = vec![0u8; READ_CHUNK];
48 let client_gone = loop {
49 tokio::select! {
50 read = pty_reader.read(&mut buf) => match read {
51 Ok(0) => break false, // shell exited
52 Ok(n) => {
53 let msg = Message {
54 id: "pty".to_owned(),
55 pty_data: Some(v1::PtyData { data: buf[..n].to_vec().into() }),
56 ..Default::default()
57 };
58 if protocol::write_message(&mut conn_writer, &msg).await.is_err() {
59 break true;
60 }
61 }
62 Err(error) => {
63 // linux returns EIO (not a clean EOF) on the master once the
64 // slave side is fully closed, so treat that as the shell
65 // exiting normally rather than a real read failure.
66 if error.raw_os_error() != Some(nix::libc::EIO) {
67 warn!(%error, "pty master read failed");
68 }
69 break false;
70 }
71 },
72 incoming = protocol::read_message(&mut conn_reader) => match incoming {
73 Ok(Some(msg)) => {
74 if let Some(data) = msg.pty_data {
75 use tokio::io::AsyncWriteExt;
76 if pty_writer.write_all(&data.data).await.is_err() {
77 break false;
78 }
79 } else if let Some(resize) = msg.pty_resize {
80 let size = Size::new(clamp_tty_dim(resize.rows), clamp_tty_dim(resize.cols));
81 if let Err(error) = pty_writer.resize(size) {
82 warn!(%error, "pty resize failed");
83 }
84 }
85 // anything else on the debug channel is ignored
86 }
87 Ok(None) => break true, // client closed the connection
88 Err(error) => {
89 warn!(%error, "debug channel read failed");
90 break true;
91 }
92 },
93 }
94 };
95
96 // if the client disconnected first, hang up the shell's process group so we
97 // don't leak a detached session. (pty-process calls setsid in the child, so
98 // it leads a new session and process group => pgid == pid.)
99 if client_gone && let Some(pid) = pid {
100 let _ = kill(Pid::from_raw(-(pid as i32)), Signal::SIGHUP);
101 }
102
103 let exit_code = match child.wait().await {
104 Ok(status) => {
105 use std::os::unix::process::ExitStatusExt;
106 status
107 .code()
108 .or_else(|| status.signal().map(|signal| 128 + signal))
109 .unwrap_or(1)
110 }
111 Err(error) => {
112 warn!(%error, "waiting on debug shell failed");
113 1
114 }
115 };
116
117 let exit = Message {
118 id: "pty".to_owned(),
119 exec_exit: Some(v1::ExecExit {
120 exit_code,
121 error: String::new(),
122 timed_out: false,
123 }),
124 ..Default::default()
125 };
126 let _ = protocol::write_message(&mut conn_writer, &exit).await;
127 info!(exit_code, "debug shell session ended");
128 Ok(())
129}
130
131struct ResolvedUser {
132 uid: u32,
133 gid: u32,
134 name: String,
135 home: String,
136 shell: String,
137}
138
139impl ResolvedUser {
140 fn env(&self, term: &str) -> Vec<(String, String)> {
141 let term = if term.is_empty() {
142 "xterm-256color"
143 } else {
144 term
145 };
146 vec![
147 ("TERM".to_owned(), term.to_owned()),
148 ("HOME".to_owned(), self.home.clone()),
149 ("USER".to_owned(), self.name.clone()),
150 ("LOGNAME".to_owned(), self.name.clone()),
151 ("SHELL".to_owned(), self.shell.clone()),
152 (
153 "PATH".to_owned(),
154 "/run/current-system/sw/bin:/usr/bin:/bin".to_owned(),
155 ),
156 ]
157 }
158}
159
160fn resolve_user(name: &str) -> Result<ResolvedUser> {
161 let user = User::from_name(name)
162 .with_context(|| format!("lookup user {name:?}"))?
163 .with_context(|| format!("debug shell user {name:?} not found"))?;
164 if user.uid.as_raw() == 0 || user.gid.as_raw() == 0 {
165 bail!("refusing to open a debug shell as privileged user {name:?}");
166 }
167 let shell = user.shell.to_string_lossy().into_owned();
168 if shell.is_empty() {
169 bail!("debug shell user {name:?} has no login shell set in the image");
170 }
171 Ok(ResolvedUser {
172 uid: user.uid.as_raw(),
173 gid: user.gid.as_raw(),
174 name: user.name,
175 home: user.dir.to_string_lossy().into_owned(),
176 shell,
177 })
178}
179
180fn clamp_tty_dim(value: u32) -> u16 {
181 value.clamp(1, u16::MAX as u32) as u16
182}