Monorepo for Tangled tangled.org
8

Configure Feed

Select the types of activity you want to include in your feed.

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}