Skip to main content

tailscale/ssh/
shell.rs

1//! A turnkey login-shell [`ChannelHandler`] for Tailscale SSH.
2//!
3//! [`ShellHandler`] runs the policy-mapped local user's login shell inside a PTY, faithfully
4//! mirroring the interactive subset of Go `tailssh`'s incubator path: a `pty-req` allocates the
5//! PTY and starts the login shell (`<shell> -l`), `window-change` resizes it, and the child's exit
6//! code is reported back as an `exit-status`.
7//!
8//! # Security
9//!
10//! This handler **spawns a real login shell and drops privileges** to the authorized user. Several
11//! invariants keep it fail-closed:
12//!
13//! * The local user comes **only** from the [`SshAccept`][crate::ssh::SshAccept] produced by the single fail-closed
14//!   authorization decision in [`auth_none`][russh::server::Handler::auth_none]. The handler never
15//!   re-evaluates policy nor falls back to a configured default user.
16//! * If the user cannot be resolved against the local passwd database, [`ShellHandler::new`]
17//!   returns `Err` and the channel is closed — **a shell is never spawned for an unknown user**.
18//! * Privileges are dropped in the child's `pre_exec` in the exact order
19//!   supplementary-groups → `setgid` → `setuid` (uid **last**, because after `setuid` the process
20//!   can no longer change its gid). Any failure aborts the `exec`, so the shell never runs with the
21//!   wrong or elevated identity. This requires the daemon to run as root; if it does not, the
22//!   `setuid`/`setgid` calls fail and the spawn fails closed.
23//! * The child environment is built from scratch (`HOME`/`USER`/`SHELL`/`PATH`/`TERM`) rather than
24//!   inherited, so the daemon's environment (which may carry secrets) never leaks into the shell.
25//! * When the matched policy rule demands **session recording**, the recorder is dialed and the
26//!   cast header written *before* the shell is spawned, so a session that must be recorded but
27//!   cannot be is never started. See [`recording`][crate::ssh::recording] for the transport and
28//!   for Go's fail-open / fail-closed rules around it.
29
30use std::{future::Future, path::PathBuf, sync::Arc};
31
32use nix::unistd::{Gid, Uid, User};
33use pty_process::{OwnedWritePty, Size};
34use russh::{ChannelId, Sig, server::Handle};
35use tokio::{
36    io::{AsyncReadExt, AsyncWriteExt},
37    sync::Mutex,
38};
39
40use crate::{
41    Device,
42    ssh::{
43        ChannelContext, ChannelEvent, ChannelHandler,
44        recording::{CastHeader, RecordingRejected, SessionRecording, TailnetDialer},
45    },
46};
47
48/// Default shell used when a resolved user has no shell set in the passwd database.
49const DEFAULT_SHELL: &str = "/bin/sh";
50
51/// Default `PATH` for the spawned login shell. The login shell itself (`-l`) will typically
52/// re-derive `PATH` from system/user profiles; this is a safe minimal baseline.
53const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
54
55/// The resolved local-user facts needed to spawn and privilege-drop into a login shell.
56///
57/// Captured up front in [`ShellHandler::new`] so the security-critical values are fixed at
58/// authorization time and not re-resolved later.
59#[derive(Debug, Clone)]
60struct ResolvedUser {
61    /// Unix login name.
62    name: String,
63    /// Numeric user id to `setuid` to.
64    uid: Uid,
65    /// Numeric primary group id to `setgid` to.
66    gid: Gid,
67    /// Home directory (used as the shell's working directory and `$HOME`).
68    home: PathBuf,
69    /// Login shell to exec (falls back to [`DEFAULT_SHELL`] if the passwd entry is empty).
70    shell: PathBuf,
71}
72
73/// Resolve `local_user` against the local passwd database.
74///
75/// **Fail-closed:** a missing entry ([`Ok(None)`]) or a lookup error both yield `Err`, so callers
76/// never proceed to spawn a shell for an unresolved user. An empty shell field is normalized to
77/// [`DEFAULT_SHELL`].
78fn resolve_user(local_user: &str) -> std::io::Result<ResolvedUser> {
79    match User::from_name(local_user) {
80        Ok(Some(user)) => {
81            let shell = if user.shell.as_os_str().is_empty() {
82                PathBuf::from(DEFAULT_SHELL)
83            } else {
84                user.shell
85            };
86            Ok(ResolvedUser {
87                name: user.name,
88                uid: user.uid,
89                gid: user.gid,
90                home: user.dir,
91                shell,
92            })
93        }
94        Ok(None) => Err(std::io::Error::new(
95            std::io::ErrorKind::NotFound,
96            format!("ssh: local user {local_user:?} not found in passwd database"),
97        )),
98        Err(e) => Err(std::io::Error::other(format!(
99            "ssh: resolving local user {local_user:?} failed: {e}"
100        ))),
101    }
102}
103
104/// Build the minimal, non-inherited environment for the login shell as `(key, value)` pairs.
105///
106/// Only `HOME`, `USER`, `LOGNAME`, `SHELL`, `PATH`, and `TERM` are set; nothing is inherited from
107/// the daemon, so its environment (potentially holding secrets) never leaks to the shell.
108fn build_env(user: &ResolvedUser) -> Vec<(String, String)> {
109    vec![
110        ("HOME".to_string(), user.home.to_string_lossy().into_owned()),
111        ("USER".to_string(), user.name.clone()),
112        ("LOGNAME".to_string(), user.name.clone()),
113        (
114            "SHELL".to_string(),
115            user.shell.to_string_lossy().into_owned(),
116        ),
117        ("PATH".to_string(), DEFAULT_PATH.to_string()),
118        ("TERM".to_string(), DEFAULT_TERM.to_string()),
119    ]
120}
121
122/// The login-shell flag (`-l`) passed to the user's shell to start it as a login shell, mirroring
123/// Go `tailssh`'s interactive path.
124const LOGIN_SHELL_ARG: &str = "-l";
125
126/// `TERM` for the spawned shell, and the value recorded in a session recording's cast header. Go
127/// falls back to the same `xterm-256color` when the client sends no `TERM`.
128const DEFAULT_TERM: &str = "xterm-256color";
129
130/// Exit status reported when a session is refused because the recording policy could not be
131/// satisfied.
132///
133/// Go uses 254 for exactly this and documents why: 1 is overloaded, 127 is "command not found",
134/// 130 is Ctrl-C, and 255 means "ssh itself failed", so 254 is the one code in the reserved >128
135/// region an operator can alert on unambiguously.
136const RECORDING_DENIED_EXIT_CODE: u32 = 254;
137
138/// The cast header for this session (Go `startNewRecording`'s `sessionrecording.CastHeader`).
139///
140/// `width`/`height` are left at zero, the value Go writes for a session with no PTY request. This
141/// fork's channel abstraction creates the session handler at channel-open, which is *before* the
142/// client's `pty-req` arrives (it is delivered later as a [`ChannelEvent::Resize`]), so the
143/// terminal size is not yet known when the header has to be written. The size is still applied to
144/// the PTY itself when the resize event arrives; only the header's advisory dimensions are absent.
145fn session_cast_header(ctx: &ChannelContext, user: &ResolvedUser) -> CastHeader {
146    let mut header = CastHeader::new(crate::ssh::now_unix_secs(), DEFAULT_TERM);
147    header.ssh_user = ctx.ssh_user.clone();
148    header.local_user = user.name.clone();
149    header.connection_id = ctx.conn_id.clone();
150
151    if let Some(node) = &ctx.src_node {
152        set_src_node(
153            &mut header,
154            node.fqdn(false),
155            node.stable_id.0.clone(),
156            &node.tags,
157            node.user_id,
158        );
159    }
160
161    header
162}
163
164/// Record the originating node in `header`.
165///
166/// Go records the *owner* of an untagged node and the *tags* of a tagged one, never both. The
167/// owner's login name is not retained by this fork's node model (see
168/// [`Device::authorize_ssh`][crate::Device::authorize_ssh]), so an untagged node contributes only
169/// its numeric user id.
170fn set_src_node(
171    header: &mut CastHeader,
172    fqdn: String,
173    stable_id: String,
174    tags: &[String],
175    user_id: i64,
176) {
177    header.src_node = fqdn;
178    header.src_node_id = stable_id;
179    if tags.is_empty() {
180        header.src_node_user_id = user_id;
181    } else {
182        header.src_node_tags = tags.to_vec();
183    }
184}
185
186/// The three client-visible steps of a refused session, behind a trait so the rule that *every*
187/// step is attempted can be tested without a live SSH connection.
188///
189/// Production is [`ChannelRefusal`] over russh's [`Handle`]. Each method reports only whether the
190/// step reached the client, because that is all the refusal path can do about it.
191trait RefusalSink {
192    /// The channel number, for logs. (russh's `ChannelId` displays as exactly this.)
193    fn channel(&self) -> u32;
194
195    /// Write the refusal message to the client.
196    fn send_message(&self, message: String) -> impl Future<Output = bool> + Send;
197
198    /// Report the refusal exit status.
199    fn send_exit_status(&self, status: u32) -> impl Future<Output = bool> + Send;
200
201    /// Close the channel.
202    fn close(&self) -> impl Future<Output = bool> + Send;
203}
204
205/// The production [`RefusalSink`]: one channel of a live russh session.
206struct ChannelRefusal<'a> {
207    /// The session the refused channel belongs to.
208    session: &'a Handle,
209    /// The refused channel.
210    channel_id: ChannelId,
211}
212
213impl RefusalSink for ChannelRefusal<'_> {
214    fn channel(&self) -> u32 {
215        self.channel_id.number()
216    }
217
218    async fn send_message(&self, message: String) -> bool {
219        self.session
220            .data(self.channel_id, message.into_bytes())
221            .await
222            .is_ok()
223    }
224
225    async fn send_exit_status(&self, status: u32) -> bool {
226        self.session
227            .exit_status_request(self.channel_id, status)
228            .await
229            .is_ok()
230    }
231
232    async fn close(&self) -> bool {
233        self.session.close(self.channel_id).await.is_ok()
234    }
235}
236
237/// Tell the client why its session is refused, then close the channel.
238///
239/// Reached only when the policy set `onRecordingFailure.rejectSessionWithMessage` and no recorder
240/// would take the recording.
241///
242/// The three steps are independent: a failure to write the message does not skip the exit status,
243/// and neither skips the close. Go's refusal path has the same shape — it writes the message with
244/// `fmt.Fprintf`, discards that write's error, and calls `ss.Exit` unconditionally — so an
245/// operator alerting on the refusal exit status still sees it when the client has stopped reading
246/// its output.
247async fn reject_session<S: RefusalSink>(sink: &S, rejected: RecordingRejected) {
248    let channel_id = sink.channel();
249    tracing::warn!(
250        %channel_id,
251        error = %rejected.cause,
252        message = %rejected.message,
253        "ssh: session refused: session recording could not be started"
254    );
255    let message_sent = sink.send_message(format!("{}\r\n", rejected.message)).await;
256    let status_sent = sink.send_exit_status(RECORDING_DENIED_EXIT_CODE).await;
257    let closed = sink.close().await;
258    if !(message_sent && status_sent && closed) {
259        tracing::debug!(
260            %channel_id,
261            message_sent,
262            status_sent,
263            closed,
264            "ssh: client gone before the refusal reached it"
265        );
266    }
267}
268
269/// Tell the client the session is being terminated, then kill the shell.
270///
271/// Reached only when the policy set `onRecordingFailure.terminateSessionWithMessage` and the
272/// recording of a *running* session failed.
273async fn end_session(
274    session: &Handle,
275    channel_id: ChannelId,
276    child: &Arc<Mutex<tokio::process::Child>>,
277    message: &str,
278) {
279    tracing::warn!(%channel_id, message, "ssh: terminating session: session recording failed");
280    if session
281        .data(channel_id, format!("\r\n{message}\r\n").into_bytes())
282        .await
283        .is_err()
284    {
285        tracing::debug!(%channel_id, "ssh: client gone before the termination notice reached it");
286    }
287    if let Err(e) = child.lock().await.start_kill() {
288        tracing::debug!(error = %e, %channel_id, "ssh: failed to kill shell after recording failure");
289    }
290}
291
292/// One privilege-drop operation, in the order it must be applied.
293///
294/// This is a pure, comparable representation of the security-critical drop sequence so the
295/// ordering invariant (uid **last**) can be unit-tested without root or a real fork. The plan is
296/// built before the fork (allocates) and applied step-by-step inside the `pre_exec` closure (no
297/// alloc, async-signal-safe).
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299enum PrivDropStep {
300    /// Set supplementary groups from the user's group membership (Linux; absent on Apple).
301    /// Carries the primary `gid` because `initgroups` needs it; storing it here keeps the
302    /// executor free of any pre-fork lookups.
303    InitGroups(Gid),
304    /// Set the real/effective/saved group id.
305    SetGid(Gid),
306    /// Set the real/effective/saved user id. MUST be last.
307    SetUid(Uid),
308}
309
310/// Build the privilege-drop plan in the sacred order: supplementary groups, then setgid, then
311/// setuid LAST (uid-last so the process cannot re-raise its gid after dropping uid). This is a
312/// pure function so the ordering invariant can be unit-tested without root or a real fork.
313///
314/// `with_initgroups` is `false` on Apple targets (where `nix` has no `initgroups`), matching the
315/// `#[cfg(not(target_vendor = "apple"))]` gating of the real call; on Apple the plan is just
316/// `[SetGid, SetUid]`.
317fn priv_drop_plan(uid: Uid, gid: Gid, with_initgroups: bool) -> Vec<PrivDropStep> {
318    let mut plan = Vec::with_capacity(3);
319    if with_initgroups {
320        plan.push(PrivDropStep::InitGroups(gid));
321    }
322    plan.push(PrivDropStep::SetGid(gid));
323    plan.push(PrivDropStep::SetUid(uid));
324    plan
325}
326
327/// Apply a single privilege-drop step via the corresponding `nix`/libc wrapper.
328///
329/// Runs post-fork inside `pre_exec`, so it must stay async-signal-safe: it only calls the libc
330/// wrappers and allocates nothing. `user_cname` is the login name needed by `initgroups`; it is
331/// `Some` only on platforms where an [`PrivDropStep::InitGroups`] step is present.
332fn apply_priv_drop_step(
333    step: &PrivDropStep,
334    user_cname: Option<&std::ffi::CStr>,
335) -> std::io::Result<()> {
336    match step {
337        PrivDropStep::InitGroups(gid) => {
338            // `initgroups` is configured out of `nix` on Apple targets, and `priv_drop_plan`
339            // never emits this step there, so the call is gated to match.
340            #[cfg(not(target_vendor = "apple"))]
341            {
342                let cname = user_cname.ok_or_else(|| {
343                    std::io::Error::other("ssh: initgroups step without user name")
344                })?;
345                nix::unistd::initgroups(cname, *gid)
346                    .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
347            }
348            #[cfg(target_vendor = "apple")]
349            {
350                let _ = (gid, user_cname);
351            }
352        }
353        PrivDropStep::SetGid(gid) => {
354            nix::unistd::setgid(*gid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
355        }
356        PrivDropStep::SetUid(uid) => {
357            nix::unistd::setuid(*uid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
358        }
359    }
360    Ok(())
361}
362
363/// A turnkey [`ChannelHandler`] that runs the authorized user's login shell in a PTY.
364///
365/// Construct one indirectly via [`Device::listen_ssh`][crate::Device::listen_ssh]; it is not meant
366/// to be created by hand.
367pub struct ShellHandler {
368    /// The russh channel this shell is bound to.
369    channel_id: ChannelId,
370    /// The owned write half of the PTY master; client input is written here, and window-resize
371    /// `TIOCSWINSZ` ioctls are issued through it.
372    pty_write: OwnedWritePty,
373    /// The spawned child shell, shared with the output-pump task so both sides can signal/kill it.
374    child: Arc<Mutex<tokio::process::Child>>,
375}
376
377impl ShellHandler {
378    /// Forward the numeric POSIX signal `signum` to the child shell, best-effort.
379    async fn signal_child(&self, signum: i32) {
380        let pid = { self.child.lock().await.id() };
381        let Some(pid) = pid else {
382            return;
383        };
384        let Ok(signal) = nix::sys::signal::Signal::try_from(signum) else {
385            tracing::debug!(signum, "ssh: unmapped signal; not forwarding");
386            return;
387        };
388        if let Err(e) =
389            nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as nix::libc::pid_t), signal)
390        {
391            tracing::debug!(error = %e, signum, "ssh: failed forwarding signal to shell");
392        }
393    }
394
395    /// Kill the child shell, best-effort. Used on channel close/EOF.
396    async fn kill_child(&self) {
397        let mut child = self.child.lock().await;
398        if let Err(e) = child.start_kill() {
399            tracing::debug!(error = %e, "ssh: failed to kill shell child");
400        }
401    }
402}
403
404/// Map a russh [`Sig`] to its POSIX signal number for forwarding to the child.
405fn sig_to_signum(sig: &Sig) -> Option<i32> {
406    Some(match sig {
407        Sig::HUP => nix::libc::SIGHUP,
408        Sig::INT => nix::libc::SIGINT,
409        Sig::QUIT => nix::libc::SIGQUIT,
410        Sig::KILL => nix::libc::SIGKILL,
411        Sig::TERM => nix::libc::SIGTERM,
412        _ => return None,
413    })
414}
415
416impl ChannelHandler for ShellHandler {
417    type Error = std::io::Error;
418
419    // This handler streams its PTY output to the policy's `recorders`, so `ChannelServer` may
420    // admit a connection whose rule demands recording; see `SessionRecording` for what happens
421    // when the recorders cannot be reached.
422    const RECORDS_SESSION: bool = true;
423
424    async fn new(
425        rt: tokio::runtime::Handle,
426        channel_id: ChannelId,
427        session: Handle,
428        dev: Arc<Device>,
429        ctx: &ChannelContext,
430    ) -> Result<Self, Self::Error> {
431        let accept = &ctx.accept;
432        // SECURITY: the identity comes solely from the fail-closed `auth_none` decision.
433        let user = resolve_user(&accept.local_user)?;
434        let env = build_env(&user);
435
436        // SECURITY: start the recording BEFORE the shell exists. Go does the same (the session
437        // handler calls `startNewRecording` and only then `launchProcess`), and the ordering is
438        // what makes a fail-closed policy mean anything: a session that must be recorded is never
439        // spawned first and recorded second.
440        let recording = if accept.recorders.is_empty() {
441            None
442        } else {
443            let header = session_cast_header(ctx, &user);
444            match SessionRecording::start(
445                &accept.recorders,
446                accept.on_recording_failure.as_ref(),
447                &header,
448                &TailnetDialer::new(dev),
449            )
450            .await
451            {
452                Ok(rec) => rec,
453                Err(rejected) => {
454                    // The policy set `rejectSessionWithMessage`: show it and refuse. The channel
455                    // is closed by the caller on `Err`, so the message is written first.
456                    reject_session(
457                        &ChannelRefusal {
458                            session: &session,
459                            channel_id,
460                        },
461                        rejected,
462                    )
463                    .await;
464                    return Err(std::io::Error::other("ssh: session recording refused"));
465                }
466            }
467        };
468
469        // Allocate the PTY master/subordinate pair.
470        let (pty, pts) = pty_process::open().map_err(std::io::Error::other)?;
471
472        // Build the privilege-drop plan BEFORE the fork (this allocates a Vec). Inside the
473        // `pre_exec` closure we only iterate + call the syscalls (no alloc, async-signal-safe).
474        //
475        // `initgroups` is unavailable on Apple targets in `nix`; it is the production (Linux)
476        // path. macOS dev builds still compile and drop the primary gid + uid (no InitGroups step,
477        // so `user_cname` is not needed there).
478        #[cfg(not(target_vendor = "apple"))]
479        let with_initgroups = true;
480        #[cfg(target_vendor = "apple")]
481        let with_initgroups = false;
482        let plan = priv_drop_plan(user.uid, user.gid, with_initgroups);
483        // The login name needed by `initgroups`; only present on the platforms that have that step.
484        #[cfg(not(target_vendor = "apple"))]
485        let user_cname = std::ffi::CString::new(user.name.clone())
486            .map_err(|e| std::io::Error::other(format!("ssh: user name has NUL byte: {e}")))?;
487
488        let mut cmd = pty_process::Command::new(&user.shell);
489        cmd = cmd.arg(LOGIN_SHELL_ARG).current_dir(&user.home).env_clear();
490        for (k, v) in env {
491            cmd = cmd.env(k, v);
492        }
493
494        // SECURITY: privilege drop runs in the child between fork and exec. Order is sacred:
495        // (1) supplementary groups, (2) setgid, (3) setuid LAST. setuid is last because once the
496        // uid is dropped the process can no longer change its gid. Any failure aborts the exec, so
497        // the shell never runs with the wrong or elevated identity. The ordered `plan` was built
498        // pre-fork (see `priv_drop_plan`); here we only iterate it and apply each step in order —
499        // behavior is identical to the previous inline initgroups→setgid→setuid sequence.
500        //
501        // Safety: the closure only calls async-signal-safe libc wrappers (initgroups/setgid/
502        // setuid) via `apply_priv_drop_step` and allocates nothing; it is sound to run post-fork.
503        cmd = unsafe {
504            cmd.pre_exec(move || {
505                #[cfg(not(target_vendor = "apple"))]
506                let user_cname = Some(user_cname.as_c_str());
507                #[cfg(target_vendor = "apple")]
508                let user_cname: Option<&std::ffi::CStr> = None;
509                for step in &plan {
510                    apply_priv_drop_step(step, user_cname)?;
511                }
512                Ok(())
513            })
514        };
515
516        let child = cmd.spawn(pts).map_err(std::io::Error::other)?;
517
518        let (mut pty_read, pty_write) = pty.into_split();
519        let child = Arc::new(Mutex::new(child));
520
521        // Pump PTY output → SSH channel data, then report the child's exit status. Runs on the
522        // shared tokio runtime so it lives independently of `handle_event` calls.
523        let pump_child = child.clone();
524        rt.spawn(async move {
525            let mut buf = [0u8; 16 * 1024];
526            let mut recording = recording;
527            // Fires with the message to show the client when the recorder upload failed and the
528            // policy says terminate (Go's `TerminateSessionWithMessage`).
529            let mut terminate = recording.as_mut().and_then(|r| r.take_terminate());
530            loop {
531                let read = tokio::select! {
532                    message = async {
533                        match terminate.as_mut() {
534                            Some(rx) => rx.await.ok(),
535                            None => std::future::pending().await,
536                        }
537                    } => {
538                        terminate = None;
539                        if let Some(message) = message {
540                            end_session(&session, channel_id, &pump_child, &message).await;
541                            break;
542                        }
543                        continue;
544                    }
545                    read = pty_read.read(&mut buf) => read,
546                };
547
548                match read {
549                    Ok(0) => break,
550                    Ok(n) => {
551                        // Only output is recorded, and it is recorded *before* it reaches the
552                        // client. Go deliberately does not record input, which may carry
553                        // passwords.
554                        if let Some(rec) = recording.as_mut()
555                            && let Err(message) = rec.record_output(&buf[..n]).await
556                        {
557                            end_session(&session, channel_id, &pump_child, &message).await;
558                            break;
559                        }
560                        if session.data(channel_id, buf[..n].to_vec()).await.is_err() {
561                            tracing::debug!(%channel_id, "ssh: client gone; stopping shell pump");
562                            break;
563                        }
564                    }
565                    Err(e) => {
566                        tracing::debug!(error = %e, %channel_id, "ssh: pty read error");
567                        break;
568                    }
569                }
570            }
571
572            // Report exit status (best-effort). russh exposes `exit_status_request(id, u32)`.
573            let status = { pump_child.lock().await.wait().await };
574            match status {
575                Ok(status) => {
576                    // A signal-killed shell has `code() == None`; reporting that as `exit-status 0`
577                    // would lie to the client (success). russh's `exit_signal_request` needs a `Sig`
578                    // name mapped from the raw signal number — awkward — so we take the simpler,
579                    // still-correct path: convey signal death as the conventional `128 + signal`
580                    // non-zero status (what a POSIX shell reports), never a bogus 0.
581                    use std::os::unix::process::ExitStatusExt as _;
582                    let code = status
583                        .code()
584                        .unwrap_or_else(|| 128 + status.signal().unwrap_or(0))
585                        as u32;
586                    if session.exit_status_request(channel_id, code).await.is_err() {
587                        tracing::debug!(%channel_id, "ssh: failed sending exit-status");
588                    }
589                }
590                Err(e) => {
591                    tracing::debug!(error = %e, %channel_id, "ssh: waiting on shell child");
592                }
593            }
594            if session.close(channel_id).await.is_err() {
595                tracing::trace!(%channel_id, "ssh: channel already closed");
596            }
597        });
598
599        Ok(Self {
600            channel_id,
601            pty_write,
602            child,
603        })
604    }
605
606    async fn handle_event(&mut self, event: &ChannelEvent) -> Result<(), Self::Error> {
607        match event {
608            ChannelEvent::Data(bytes) => {
609                self.pty_write.write_all(bytes).await?;
610                self.pty_write.flush().await?;
611            }
612            ChannelEvent::Resize { width, height } => {
613                // `pty-req` initial size and later `window-change` both arrive here. Issue
614                // TIOCSWINSZ via pty-process' resize (rows, cols).
615                if let Err(e) = self.pty_write.resize(Size::new(*height, *width)) {
616                    tracing::debug!(error = %e, channel_id = %self.channel_id, "ssh: pty resize");
617                }
618            }
619            ChannelEvent::Signal(sig) => {
620                if let Some(signum) = sig_to_signum(sig) {
621                    self.signal_child(signum).await;
622                } else {
623                    tracing::debug!(?sig, "ssh: unhandled signal; not forwarding");
624                }
625            }
626            ChannelEvent::Close | ChannelEvent::Eof => {
627                tracing::debug!(channel_id = %self.channel_id, ?event, "ssh: closing shell");
628                self.kill_child().await;
629            }
630        }
631        Ok(())
632    }
633}
634
635#[cfg(all(test, feature = "ssh"))]
636mod tests {
637    use super::*;
638
639    fn fake_user() -> ResolvedUser {
640        ResolvedUser {
641            name: "alice".to_string(),
642            uid: Uid::from_raw(1000),
643            gid: Gid::from_raw(1000),
644            home: PathBuf::from("/home/alice"),
645            shell: PathBuf::from("/bin/bash"),
646        }
647    }
648
649    #[test]
650    fn env_is_minimal_and_correct() {
651        let env = build_env(&fake_user());
652        let get = |k: &str| {
653            env.iter()
654                .find(|(key, _)| key == k)
655                .map(|(_, v)| v.as_str())
656        };
657
658        assert_eq!(get("HOME"), Some("/home/alice"));
659        assert_eq!(get("USER"), Some("alice"));
660        assert_eq!(get("LOGNAME"), Some("alice"));
661        assert_eq!(get("SHELL"), Some("/bin/bash"));
662        assert_eq!(get("TERM"), Some("xterm-256color"));
663        assert_eq!(get("PATH"), Some(DEFAULT_PATH));
664        // No daemon environment leaks through: only the six known keys are present.
665        assert_eq!(env.len(), 6);
666    }
667
668    #[test]
669    fn resolve_unknown_user_fails_closed() {
670        // A username that cannot exist in any passwd database must yield Err, never a shell.
671        let err = resolve_user("definitely-not-a-real-user-xyz")
672            .expect_err("bogus user must fail closed");
673        assert!(matches!(
674            err.kind(),
675            std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
676        ));
677    }
678
679    #[test]
680    fn login_shell_uses_dash_l() {
681        // The interactive path always starts a login shell with `-l`. The exec form
682        // (`<shell> -c <cmd>`) is documented as unsupported because `ChannelEvent` carries no
683        // exec request; see the module note in `Device::listen_ssh`.
684        assert_eq!(LOGIN_SHELL_ARG, "-l");
685    }
686
687    #[test]
688    fn priv_drop_plan_orders_uid_last() {
689        let uid = Uid::from_raw(1000);
690        let gid = Gid::from_raw(1000);
691        // Linux production path includes the supplementary-groups step first.
692        let plan = priv_drop_plan(uid, gid, true);
693        assert_eq!(
694            plan,
695            vec![
696                PrivDropStep::InitGroups(gid),
697                PrivDropStep::SetGid(gid),
698                PrivDropStep::SetUid(uid),
699            ],
700            "drop sequence must be initgroups → setgid → setuid"
701        );
702        // setuid MUST be last — fails loudly if anyone reorders.
703        assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
704    }
705
706    #[test]
707    fn priv_drop_plan_apple_skips_initgroups() {
708        let uid = Uid::from_raw(1000);
709        let gid = Gid::from_raw(1000);
710        // Apple path: `initgroups` is unavailable, so no InitGroups step — but still uid-last.
711        let plan = priv_drop_plan(uid, gid, false);
712        assert_eq!(
713            plan,
714            vec![PrivDropStep::SetGid(gid), PrivDropStep::SetUid(uid)],
715        );
716        assert!(!plan.contains(&PrivDropStep::InitGroups(gid)));
717        assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
718    }
719
720    #[test]
721    fn priv_drop_setgid_before_setuid() {
722        let uid = Uid::from_raw(1000);
723        let gid = Gid::from_raw(1000);
724        // The sacred invariant expressed directly: gid is dropped before uid, on every platform.
725        for with_initgroups in [true, false] {
726            let plan = priv_drop_plan(uid, gid, with_initgroups);
727            let setgid_idx = plan
728                .iter()
729                .position(|s| *s == PrivDropStep::SetGid(gid))
730                .expect("plan must set gid");
731            let setuid_idx = plan
732                .iter()
733                .position(|s| *s == PrivDropStep::SetUid(uid))
734                .expect("plan must set uid");
735            assert!(
736                setgid_idx < setuid_idx,
737                "setgid must precede setuid (with_initgroups={with_initgroups})"
738            );
739        }
740    }
741
742    /// A [`ChannelContext`] with no resolved peer, carrying the facts the cast header needs.
743    fn ctx() -> ChannelContext {
744        ChannelContext {
745            accept: crate::ssh::SshAccept {
746                local_user: "ubuntu".to_string(),
747                accept_env: Vec::new(),
748                session_duration_nanos: None,
749                allow_agent_forwarding: false,
750                allow_local_port_forwarding: false,
751                allow_remote_port_forwarding: false,
752                recorders: Vec::new(),
753                on_recording_failure: None,
754                hold_and_delegate: String::new(),
755                recording_refusal_message: String::new(),
756            },
757            ssh_user: "operator".to_string(),
758            remote: "100.64.0.7:52344".parse().unwrap(),
759            src_node: None,
760            conn_id: "ssh-conn-20231114T221320-0011223344".to_string(),
761        }
762    }
763
764    /// The cast header identifies the session: the username the client asked for, the local user
765    /// it was mapped to, the connection it belongs to, and the terminal type.
766    #[test]
767    fn cast_header_describes_the_session() {
768        let header = session_cast_header(&ctx(), &fake_user());
769        assert_eq!(
770            header.ssh_user, "operator",
771            "the username the client presented"
772        );
773        assert_eq!(
774            header.local_user,
775            fake_user().name,
776            "the local user the policy mapped it to"
777        );
778        assert_eq!(header.connection_id, "ssh-conn-20231114T221320-0011223344");
779        assert_eq!(
780            header.env.get("TERM").map(String::as_str),
781            Some(DEFAULT_TERM)
782        );
783        // No PTY size is known when the handler is built; see `session_cast_header`.
784        assert_eq!((header.width, header.height), (0, 0));
785        // With no resolved peer nothing about the source node is invented.
786        assert!(header.src_node.is_empty());
787        assert!(header.src_node_id.is_empty());
788        assert_eq!(header.src_node_user_id, 0);
789        assert!(header.src_node_tags.is_empty());
790    }
791
792    /// An untagged node contributes its owner id; a tagged node contributes its tags. Never both,
793    /// which is Go's rule.
794    #[test]
795    fn src_node_records_owner_or_tags_never_both() {
796        let mut untagged = CastHeader::new(0, DEFAULT_TERM);
797        set_src_node(
798            &mut untagged,
799            "laptop.tail-scale.ts.net".to_string(),
800            "nodeid-abc".to_string(),
801            &[],
802            42,
803        );
804        assert_eq!(untagged.src_node, "laptop.tail-scale.ts.net");
805        assert_eq!(untagged.src_node_id, "nodeid-abc");
806        assert_eq!(untagged.src_node_user_id, 42);
807        assert!(untagged.src_node_tags.is_empty());
808
809        let mut tagged = CastHeader::new(0, DEFAULT_TERM);
810        set_src_node(
811            &mut tagged,
812            "ci.tail-scale.ts.net".to_string(),
813            "nodeid-def".to_string(),
814            &["tag:ci".to_string()],
815            42,
816        );
817        assert_eq!(tagged.src_node_tags, vec!["tag:ci".to_string()]);
818        assert_eq!(
819            tagged.src_node_user_id, 0,
820            "a tagged node has no human owner to record"
821        );
822    }
823
824    /// The refusal exit status is the one Go reserves for a denied recording-required session.
825    #[test]
826    fn recording_refusal_uses_the_reserved_exit_code() {
827        assert_eq!(RECORDING_DENIED_EXIT_CODE, 254);
828    }
829
830    /// A [`RefusalSink`] that records the steps it was asked to perform, and can be told to fail
831    /// the message write so the independence of the later steps is observable.
832    #[derive(Default)]
833    struct FakeRefusal {
834        /// When set, `send_message` reports the write as not having reached the client.
835        message_fails: bool,
836        /// Every step attempted, in order.
837        steps: std::sync::Mutex<Vec<String>>,
838    }
839
840    impl FakeRefusal {
841        fn steps(&self) -> Vec<String> {
842            self.steps.lock().expect("steps mutex").clone()
843        }
844    }
845
846    impl RefusalSink for FakeRefusal {
847        fn channel(&self) -> u32 {
848            7
849        }
850
851        async fn send_message(&self, message: String) -> bool {
852            self.steps
853                .lock()
854                .expect("steps mutex")
855                .push(format!("message:{message:?}"));
856            !self.message_fails
857        }
858
859        async fn send_exit_status(&self, status: u32) -> bool {
860            self.steps
861                .lock()
862                .expect("steps mutex")
863                .push(format!("exit-status:{status}"));
864            true
865        }
866
867        async fn close(&self) -> bool {
868            self.steps.lock().expect("steps mutex").push("close".into());
869            true
870        }
871    }
872
873    fn rejection() -> RecordingRejected {
874        RecordingRejected {
875            message: "this session must be recorded".to_string(),
876            cause: crate::ssh::recording::RecorderError::NoRecorders,
877        }
878    }
879
880    /// The happy path: the policy's message reaches the client CRLF-terminated, then the reserved
881    /// exit status, then the close.
882    #[tokio::test]
883    async fn refusal_writes_message_then_exit_status_then_close() {
884        let sink = FakeRefusal::default();
885        reject_session(&sink, rejection()).await;
886        assert_eq!(
887            sink.steps(),
888            vec![
889                "message:\"this session must be recorded\\r\\n\"".to_string(),
890                "exit-status:254".to_string(),
891                "close".to_string(),
892            ],
893        );
894    }
895
896    /// A client that has stopped reading must still be sent the refusal exit status and have its
897    /// channel closed. The message write failing is not a reason to skip either — an operator
898    /// alerting on exit 254 would otherwise never see the refusal.
899    #[tokio::test]
900    async fn refusal_sends_exit_status_even_when_the_message_write_fails() {
901        let sink = FakeRefusal {
902            message_fails: true,
903            ..FakeRefusal::default()
904        };
905        reject_session(&sink, rejection()).await;
906        assert_eq!(
907            sink.steps(),
908            vec![
909                "message:\"this session must be recorded\\r\\n\"".to_string(),
910                "exit-status:254".to_string(),
911                "close".to_string(),
912            ],
913            "a failed message write must not short-circuit the exit status or the close",
914        );
915    }
916
917    #[test]
918    fn empty_shell_falls_back_to_default() {
919        // Mirror resolve_user's normalization of an empty passwd shell field.
920        let mut u = fake_user();
921        u.shell = PathBuf::from("");
922        let shell = if u.shell.as_os_str().is_empty() {
923            PathBuf::from(DEFAULT_SHELL)
924        } else {
925            u.shell.clone()
926        };
927        assert_eq!(shell, PathBuf::from(DEFAULT_SHELL));
928    }
929}