···11use anyhow::{Context, Result};
22use nix::sys::signal::{Signal, kill};
33use nix::unistd::{Gid, Pid, Uid, User, getgrouplist, setgid, setgroups, setuid};
44-use std::ffi::{CString, OsStr, OsString};
44+use pty_process::{Command as PtyCommand, OwnedReadPty, OwnedWritePty, Size};
55+use std::ffi::{CString, OsString};
56use std::io;
67use std::os::unix::process::ExitStatusExt;
78use std::path::PathBuf;
···215216 // group won't actually let it access the sock.
216217 // https://github.com/rust-lang/rust/issues/90747
217218 if let (Some(uid), Some(gid)) = (spec.uid, spec.gid) {
218218- let username = User::from_uid(Uid::from_raw(uid))
219219- .ok()
220220- .flatten()
221221- .map(|u| u.name)
222222- .with_context(|| format!("lookup passwd entry for uid {uid}"))?;
223223- let cname = CString::new(username)
224224- .with_context(|| format!("username for uid {uid} contained a null byte"))?;
225225- // resolve groups beforehand so we don't have to read /etc/group in the pre_exec
226226- let groups =
227227- getgrouplist(&cname, Gid::from_raw(gid)).context("resolve supplementary groups")?;
219219+ let groups = resolve_supplementary_groups(uid, gid)?;
228220 // SAFETY: pre_exec runs between fork and execve in the child.
229221 // we only call async-signal-safe syscalls and we don't touch any
230222 // shared state, no allocator, no mutexes, no globals.
···242234 cmd.process_group(0);
243235244236 cmd.spawn()
245245- .with_context(|| format!("spawn {}", display_os(&spec.program)))
237237+ .with_context(|| format!("spawn {:?}", &spec.program))
238238+}
239239+240240+// resolve the supplementary group list up front so the pre_exec hook never has
241241+// to read /etc/group (which is not async-signal-safe) between fork and exec.
242242+fn resolve_supplementary_groups(uid: u32, gid: u32) -> Result<Vec<Gid>> {
243243+ let username = User::from_uid(Uid::from_raw(uid))
244244+ .ok()
245245+ .flatten()
246246+ .map(|u| u.name)
247247+ .with_context(|| format!("lookup passwd entry for uid {uid}"))?;
248248+ let cname = CString::new(username)
249249+ .with_context(|| format!("username for uid {uid} contained a null byte"))?;
250250+ getgrouplist(&cname, Gid::from_raw(gid)).context("resolve supplementary groups")
251251+}
252252+253253+pub fn spawn_pty(spec: Spec, rows: u16, cols: u16) -> Result<(OwnedReadPty, OwnedWritePty, Child)> {
254254+ let (pty, pts) = pty_process::open().context("open pty")?;
255255+ pty.resize(Size::new(rows, cols)).context("set pty size")?;
256256+257257+ let mut cmd = PtyCommand::new(&spec.program)
258258+ .args(&spec.args)
259259+ .envs(spec.env.iter().map(|(key, value)| (key, value)));
260260+ if let Some(cwd) = &spec.cwd {
261261+ cmd = cmd.current_dir(cwd);
262262+ }
263263+264264+ // drop privileges in the child. this RELIES on pty-process composing our
265265+ // pre_exec hook *after* its own session setup: it wraps us as `move || {
266266+ // session_leader()?; ours()?; }`, so setsid + TIOCSCTTY run first (while
267267+ // still privileged) and only then do we drop to the workflow user. that
268268+ // ordering is what we want and we depend on it. if pty-process ever ran our
269269+ // hook first, the session setup would happen post-drop. (it'd likely still
270270+ // work, since setsid/TIOCSCTTY on our own pty need no privilege, but it is
271271+ // not the behaviour we're assuming here)
272272+ // don't use .uid()/.gid() here, they clear supplementary groups (see L195).
273273+ if let (Some(uid), Some(gid)) = (spec.uid, spec.gid) {
274274+ let groups = resolve_supplementary_groups(uid, gid)?;
275275+ // SAFETY: pre_exec runs between fork and execve in the child. every call
276276+ // below is async-signal-safe and touches no shared state.
277277+ cmd = unsafe {
278278+ cmd.pre_exec(move || {
279279+ setgroups(&groups).map_err(io::Error::from)?;
280280+ setgid(Gid::from_raw(gid)).map_err(io::Error::from)?;
281281+ setuid(Uid::from_raw(uid)).map_err(io::Error::from)?;
282282+ Ok(())
283283+ })
284284+ };
285285+ }
286286+287287+ // spawn consumes the slave (dup'd onto the child's 0/1/2 and then closed in
288288+ // the parent), so the master reports EOF once the shell and all its children
289289+ // have exited.
290290+ let child = cmd
291291+ .spawn(pts)
292292+ .with_context(|| format!("spawn pty shell {:?}", &spec.program))?;
293293+294294+ let (reader, writer) = pty.into_split();
295295+ Ok((reader, writer, child))
246296}
247297248298async fn wait_child(
···327377 })
328378}
329379330330-fn display_os(value: &OsStr) -> String {
331331- value.to_string_lossy().into_owned()
380380+#[cfg(test)]
381381+mod tests {
382382+ use super::*;
383383+ use tokio::io::AsyncReadExt;
384384+385385+ #[tokio::test]
386386+ async fn pty_runs_a_shell_and_reports_exit() {
387387+ let spec = Spec::new("/bin/sh")
388388+ .arg("-c")
389389+ .arg("printf 'hello pty'; exit 7");
390390+ let (mut reader, _writer, mut child) = spawn_pty(spec, 24, 80).expect("spawn pty");
391391+392392+ let mut output = Vec::new();
393393+ let mut chunk = [0u8; 1024];
394394+ loop {
395395+ match reader.read(&mut chunk).await {
396396+ Ok(0) => break,
397397+ Ok(n) => output.extend_from_slice(&chunk[..n]),
398398+ // linux signals slave-closed with EIO rather than EOF
399399+ Err(error) if error.raw_os_error() == Some(nix::libc::EIO) => break,
400400+ Err(error) => panic!("read pty master: {error}"),
401401+ }
402402+ }
403403+404404+ let status = child.wait().await.expect("wait child");
405405+ let text = String::from_utf8_lossy(&output);
406406+ assert!(text.contains("hello pty"), "unexpected output: {text:?}");
407407+ assert_eq!(status.code(), Some(7));
408408+ }
409409+410410+ #[tokio::test]
411411+ async fn pty_resize_succeeds() {
412412+ let spec = Spec::new("/bin/sh").arg("-c").arg("sleep 0.2");
413413+ let (_reader, writer, mut child) = spawn_pty(spec, 24, 80).expect("spawn pty");
414414+ writer.resize(Size::new(40, 120)).expect("resize");
415415+ let _ = child.wait().await;
416416+ }
332417}
···4141spindle expects, they should work. That is:
4242- a guest agent is present inside of the image and when that image boots it will
4343 get started,
4444-- `spindle-workflow` user exists,
4545-- and the work directory is configured (`/workspace`).
4444+- the `spindle-workflow` user exists, is unprivileged (non-zero uid/gid), and has
4545+ a usable login shell and home dir set in the image's passwd: workflow steps run
4646+ as this user, and the debug shell (see below) launches its passwd shell as a
4747+ login shell in its home dir. an unset or `nologin`/`false` shell breaks debug
4848+ ssh,
4949+- and the work directory is configured (`/workspace`, with `/workspace/repo` as
5050+ the per-step working dir).
46514752## Image discovery
4853···234239never made it to the destination store. The guest still only ever sees the same
235240HTTP binary-cache upload protocol over vsock; it never gets direct access to
236241SSH credentials or the destination store itself.
242242+243243+### Debug ssh
244244+245245+When a workflow fails, spindle can keep its microVM alive for a configured grace
246246+window (`MicroVMPipelines.SSH`) and print an `ssh` invocation so you can poke at
247247+the failed VM interactively. Spindle terminates the ssh connection itself and
248248+bridges a pty into the live guest over the agent's vsock; the guest stays
249249+keyless and never runs an ssh daemon.
250250+251251+Access mirrors a git push: the ssh username is the job id, and the offered
252252+public key is sent to the job's repo knot (`sh.tangled.repo.checkPushAllowed`).
253253+The session is accepted only if that key is allowed to push to the job's repo.
254254+255255+The shell is deliberately not configurable from either end. It always:
256256+- runs as the `spindle-workflow` user (the ssh username selects the *job*, not a
257257+ unix user),
258258+- uses that user's login shell from the image's passwd, launched as a login
259259+ shell (`-l`), and
260260+- starts in the dir where the repo was cloned to.
261261+262262+The only things the client influences are the terminal type and window size
263263+(forwarded from the ssh pty request, and on resize). This relies on the image
264264+configuring `spindle-workflow` properly per the expectations above; in
265265+particular a missing or `nologin`/`false` won't work of course.
···169169 CacheReadURLs []string
170170 CacheTrustedPublicKeys []string
171171 VM VMHandle
172172+ CID uint32
172173 Agent *AgentSession
173174 ReadCache *ReadCacheProxy
174175 UploadCache *UploadCacheProxy
175176 DNSProxy *DNSProxy
176177 WorkDir string
177178 NixOSToplevelCache nixosToplevelCacheStore
179179+ StartedAt time.Time // when the VM booted, for the max-lifetime cap
178180}
179181180182func (e *Engine) cleanupState(ctx context.Context, wid models.WorkflowId, state *workflowState) error {
181183 if state == nil {
182184 return nil
183185 }
186186+187187+ // stop advertising this VM for debug shells before we tear it down
188188+ e.unregisterDebugTarget(wid)
184189185190 ctx = context.WithoutCancel(ctx)
186191