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::{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/// Tell the client why its session is refused, then close the channel.
187///
188/// Reached only when the policy set `onRecordingFailure.rejectSessionWithMessage` and no recorder
189/// would take the recording.
190async fn reject_session(session: &Handle, channel_id: ChannelId, rejected: RecordingRejected) {
191 tracing::warn!(
192 %channel_id,
193 error = %rejected.cause,
194 message = %rejected.message,
195 "ssh: session refused: session recording could not be started"
196 );
197 let refused = session
198 .data(channel_id, format!("{}\r\n", rejected.message).into_bytes())
199 .await
200 .is_err()
201 || session
202 .exit_status_request(channel_id, RECORDING_DENIED_EXIT_CODE)
203 .await
204 .is_err()
205 || session.close(channel_id).await.is_err();
206 if refused {
207 tracing::debug!(%channel_id, "ssh: client gone before the refusal reached it");
208 }
209}
210
211/// Tell the client the session is being terminated, then kill the shell.
212///
213/// Reached only when the policy set `onRecordingFailure.terminateSessionWithMessage` and the
214/// recording of a *running* session failed.
215async fn end_session(
216 session: &Handle,
217 channel_id: ChannelId,
218 child: &Arc<Mutex<tokio::process::Child>>,
219 message: &str,
220) {
221 tracing::warn!(%channel_id, message, "ssh: terminating session: session recording failed");
222 if session
223 .data(channel_id, format!("\r\n{message}\r\n").into_bytes())
224 .await
225 .is_err()
226 {
227 tracing::debug!(%channel_id, "ssh: client gone before the termination notice reached it");
228 }
229 if let Err(e) = child.lock().await.start_kill() {
230 tracing::debug!(error = %e, %channel_id, "ssh: failed to kill shell after recording failure");
231 }
232}
233
234/// One privilege-drop operation, in the order it must be applied.
235///
236/// This is a pure, comparable representation of the security-critical drop sequence so the
237/// ordering invariant (uid **last**) can be unit-tested without root or a real fork. The plan is
238/// built before the fork (allocates) and applied step-by-step inside the `pre_exec` closure (no
239/// alloc, async-signal-safe).
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241enum PrivDropStep {
242 /// Set supplementary groups from the user's group membership (Linux; absent on Apple).
243 /// Carries the primary `gid` because `initgroups` needs it; storing it here keeps the
244 /// executor free of any pre-fork lookups.
245 InitGroups(Gid),
246 /// Set the real/effective/saved group id.
247 SetGid(Gid),
248 /// Set the real/effective/saved user id. MUST be last.
249 SetUid(Uid),
250}
251
252/// Build the privilege-drop plan in the sacred order: supplementary groups, then setgid, then
253/// setuid LAST (uid-last so the process cannot re-raise its gid after dropping uid). This is a
254/// pure function so the ordering invariant can be unit-tested without root or a real fork.
255///
256/// `with_initgroups` is `false` on Apple targets (where `nix` has no `initgroups`), matching the
257/// `#[cfg(not(target_vendor = "apple"))]` gating of the real call; on Apple the plan is just
258/// `[SetGid, SetUid]`.
259fn priv_drop_plan(uid: Uid, gid: Gid, with_initgroups: bool) -> Vec<PrivDropStep> {
260 let mut plan = Vec::with_capacity(3);
261 if with_initgroups {
262 plan.push(PrivDropStep::InitGroups(gid));
263 }
264 plan.push(PrivDropStep::SetGid(gid));
265 plan.push(PrivDropStep::SetUid(uid));
266 plan
267}
268
269/// Apply a single privilege-drop step via the corresponding `nix`/libc wrapper.
270///
271/// Runs post-fork inside `pre_exec`, so it must stay async-signal-safe: it only calls the libc
272/// wrappers and allocates nothing. `user_cname` is the login name needed by `initgroups`; it is
273/// `Some` only on platforms where an [`PrivDropStep::InitGroups`] step is present.
274fn apply_priv_drop_step(
275 step: &PrivDropStep,
276 user_cname: Option<&std::ffi::CStr>,
277) -> std::io::Result<()> {
278 match step {
279 PrivDropStep::InitGroups(gid) => {
280 // `initgroups` is configured out of `nix` on Apple targets, and `priv_drop_plan`
281 // never emits this step there, so the call is gated to match.
282 #[cfg(not(target_vendor = "apple"))]
283 {
284 let cname = user_cname.ok_or_else(|| {
285 std::io::Error::other("ssh: initgroups step without user name")
286 })?;
287 nix::unistd::initgroups(cname, *gid)
288 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
289 }
290 #[cfg(target_vendor = "apple")]
291 {
292 let _ = (gid, user_cname);
293 }
294 }
295 PrivDropStep::SetGid(gid) => {
296 nix::unistd::setgid(*gid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
297 }
298 PrivDropStep::SetUid(uid) => {
299 nix::unistd::setuid(*uid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
300 }
301 }
302 Ok(())
303}
304
305/// A turnkey [`ChannelHandler`] that runs the authorized user's login shell in a PTY.
306///
307/// Construct one indirectly via [`Device::listen_ssh`][crate::Device::listen_ssh]; it is not meant
308/// to be created by hand.
309pub struct ShellHandler {
310 /// The russh channel this shell is bound to.
311 channel_id: ChannelId,
312 /// The owned write half of the PTY master; client input is written here, and window-resize
313 /// `TIOCSWINSZ` ioctls are issued through it.
314 pty_write: OwnedWritePty,
315 /// The spawned child shell, shared with the output-pump task so both sides can signal/kill it.
316 child: Arc<Mutex<tokio::process::Child>>,
317}
318
319impl ShellHandler {
320 /// Forward the numeric POSIX signal `signum` to the child shell, best-effort.
321 async fn signal_child(&self, signum: i32) {
322 let pid = { self.child.lock().await.id() };
323 let Some(pid) = pid else {
324 return;
325 };
326 let Ok(signal) = nix::sys::signal::Signal::try_from(signum) else {
327 tracing::debug!(signum, "ssh: unmapped signal; not forwarding");
328 return;
329 };
330 if let Err(e) =
331 nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as nix::libc::pid_t), signal)
332 {
333 tracing::debug!(error = %e, signum, "ssh: failed forwarding signal to shell");
334 }
335 }
336
337 /// Kill the child shell, best-effort. Used on channel close/EOF.
338 async fn kill_child(&self) {
339 let mut child = self.child.lock().await;
340 if let Err(e) = child.start_kill() {
341 tracing::debug!(error = %e, "ssh: failed to kill shell child");
342 }
343 }
344}
345
346/// Map a russh [`Sig`] to its POSIX signal number for forwarding to the child.
347fn sig_to_signum(sig: &Sig) -> Option<i32> {
348 Some(match sig {
349 Sig::HUP => nix::libc::SIGHUP,
350 Sig::INT => nix::libc::SIGINT,
351 Sig::QUIT => nix::libc::SIGQUIT,
352 Sig::KILL => nix::libc::SIGKILL,
353 Sig::TERM => nix::libc::SIGTERM,
354 _ => return None,
355 })
356}
357
358impl ChannelHandler for ShellHandler {
359 type Error = std::io::Error;
360
361 // This handler streams its PTY output to the policy's `recorders`, so `ChannelServer` may
362 // admit a connection whose rule demands recording; see `SessionRecording` for what happens
363 // when the recorders cannot be reached.
364 const RECORDS_SESSION: bool = true;
365
366 async fn new(
367 rt: tokio::runtime::Handle,
368 channel_id: ChannelId,
369 session: Handle,
370 dev: Arc<Device>,
371 ctx: &ChannelContext,
372 ) -> Result<Self, Self::Error> {
373 let accept = &ctx.accept;
374 // SECURITY: the identity comes solely from the fail-closed `auth_none` decision.
375 let user = resolve_user(&accept.local_user)?;
376 let env = build_env(&user);
377
378 // SECURITY: start the recording BEFORE the shell exists. Go does the same (the session
379 // handler calls `startNewRecording` and only then `launchProcess`), and the ordering is
380 // what makes a fail-closed policy mean anything: a session that must be recorded is never
381 // spawned first and recorded second.
382 let recording = if accept.recorders.is_empty() {
383 None
384 } else {
385 let header = session_cast_header(ctx, &user);
386 match SessionRecording::start(
387 &accept.recorders,
388 accept.on_recording_failure.as_ref(),
389 &header,
390 &TailnetDialer::new(dev),
391 )
392 .await
393 {
394 Ok(rec) => rec,
395 Err(rejected) => {
396 // The policy set `rejectSessionWithMessage`: show it and refuse. The channel
397 // is closed by the caller on `Err`, so the message is written first.
398 reject_session(&session, channel_id, rejected).await;
399 return Err(std::io::Error::other("ssh: session recording refused"));
400 }
401 }
402 };
403
404 // Allocate the PTY master/subordinate pair.
405 let (pty, pts) = pty_process::open().map_err(std::io::Error::other)?;
406
407 // Build the privilege-drop plan BEFORE the fork (this allocates a Vec). Inside the
408 // `pre_exec` closure we only iterate + call the syscalls (no alloc, async-signal-safe).
409 //
410 // `initgroups` is unavailable on Apple targets in `nix`; it is the production (Linux)
411 // path. macOS dev builds still compile and drop the primary gid + uid (no InitGroups step,
412 // so `user_cname` is not needed there).
413 #[cfg(not(target_vendor = "apple"))]
414 let with_initgroups = true;
415 #[cfg(target_vendor = "apple")]
416 let with_initgroups = false;
417 let plan = priv_drop_plan(user.uid, user.gid, with_initgroups);
418 // The login name needed by `initgroups`; only present on the platforms that have that step.
419 #[cfg(not(target_vendor = "apple"))]
420 let user_cname = std::ffi::CString::new(user.name.clone())
421 .map_err(|e| std::io::Error::other(format!("ssh: user name has NUL byte: {e}")))?;
422
423 let mut cmd = pty_process::Command::new(&user.shell);
424 cmd = cmd.arg(LOGIN_SHELL_ARG).current_dir(&user.home).env_clear();
425 for (k, v) in env {
426 cmd = cmd.env(k, v);
427 }
428
429 // SECURITY: privilege drop runs in the child between fork and exec. Order is sacred:
430 // (1) supplementary groups, (2) setgid, (3) setuid LAST. setuid is last because once the
431 // uid is dropped the process can no longer change its gid. Any failure aborts the exec, so
432 // the shell never runs with the wrong or elevated identity. The ordered `plan` was built
433 // pre-fork (see `priv_drop_plan`); here we only iterate it and apply each step in order —
434 // behavior is identical to the previous inline initgroups→setgid→setuid sequence.
435 //
436 // Safety: the closure only calls async-signal-safe libc wrappers (initgroups/setgid/
437 // setuid) via `apply_priv_drop_step` and allocates nothing; it is sound to run post-fork.
438 cmd = unsafe {
439 cmd.pre_exec(move || {
440 #[cfg(not(target_vendor = "apple"))]
441 let user_cname = Some(user_cname.as_c_str());
442 #[cfg(target_vendor = "apple")]
443 let user_cname: Option<&std::ffi::CStr> = None;
444 for step in &plan {
445 apply_priv_drop_step(step, user_cname)?;
446 }
447 Ok(())
448 })
449 };
450
451 let child = cmd.spawn(pts).map_err(std::io::Error::other)?;
452
453 let (mut pty_read, pty_write) = pty.into_split();
454 let child = Arc::new(Mutex::new(child));
455
456 // Pump PTY output → SSH channel data, then report the child's exit status. Runs on the
457 // shared tokio runtime so it lives independently of `handle_event` calls.
458 let pump_child = child.clone();
459 rt.spawn(async move {
460 let mut buf = [0u8; 16 * 1024];
461 let mut recording = recording;
462 // Fires with the message to show the client when the recorder upload failed and the
463 // policy says terminate (Go's `TerminateSessionWithMessage`).
464 let mut terminate = recording.as_mut().and_then(|r| r.take_terminate());
465 loop {
466 let read = tokio::select! {
467 message = async {
468 match terminate.as_mut() {
469 Some(rx) => rx.await.ok(),
470 None => std::future::pending().await,
471 }
472 } => {
473 terminate = None;
474 if let Some(message) = message {
475 end_session(&session, channel_id, &pump_child, &message).await;
476 break;
477 }
478 continue;
479 }
480 read = pty_read.read(&mut buf) => read,
481 };
482
483 match read {
484 Ok(0) => break,
485 Ok(n) => {
486 // Only output is recorded, and it is recorded *before* it reaches the
487 // client. Go deliberately does not record input, which may carry
488 // passwords.
489 if let Some(rec) = recording.as_mut()
490 && let Err(message) = rec.record_output(&buf[..n]).await
491 {
492 end_session(&session, channel_id, &pump_child, &message).await;
493 break;
494 }
495 if session.data(channel_id, buf[..n].to_vec()).await.is_err() {
496 tracing::debug!(%channel_id, "ssh: client gone; stopping shell pump");
497 break;
498 }
499 }
500 Err(e) => {
501 tracing::debug!(error = %e, %channel_id, "ssh: pty read error");
502 break;
503 }
504 }
505 }
506
507 // Report exit status (best-effort). russh exposes `exit_status_request(id, u32)`.
508 let status = { pump_child.lock().await.wait().await };
509 match status {
510 Ok(status) => {
511 // A signal-killed shell has `code() == None`; reporting that as `exit-status 0`
512 // would lie to the client (success). russh's `exit_signal_request` needs a `Sig`
513 // name mapped from the raw signal number — awkward — so we take the simpler,
514 // still-correct path: convey signal death as the conventional `128 + signal`
515 // non-zero status (what a POSIX shell reports), never a bogus 0.
516 use std::os::unix::process::ExitStatusExt as _;
517 let code = status
518 .code()
519 .unwrap_or_else(|| 128 + status.signal().unwrap_or(0))
520 as u32;
521 if session.exit_status_request(channel_id, code).await.is_err() {
522 tracing::debug!(%channel_id, "ssh: failed sending exit-status");
523 }
524 }
525 Err(e) => {
526 tracing::debug!(error = %e, %channel_id, "ssh: waiting on shell child");
527 }
528 }
529 if session.close(channel_id).await.is_err() {
530 tracing::trace!(%channel_id, "ssh: channel already closed");
531 }
532 });
533
534 Ok(Self {
535 channel_id,
536 pty_write,
537 child,
538 })
539 }
540
541 async fn handle_event(&mut self, event: &ChannelEvent) -> Result<(), Self::Error> {
542 match event {
543 ChannelEvent::Data(bytes) => {
544 self.pty_write.write_all(bytes).await?;
545 self.pty_write.flush().await?;
546 }
547 ChannelEvent::Resize { width, height } => {
548 // `pty-req` initial size and later `window-change` both arrive here. Issue
549 // TIOCSWINSZ via pty-process' resize (rows, cols).
550 if let Err(e) = self.pty_write.resize(Size::new(*height, *width)) {
551 tracing::debug!(error = %e, channel_id = %self.channel_id, "ssh: pty resize");
552 }
553 }
554 ChannelEvent::Signal(sig) => {
555 if let Some(signum) = sig_to_signum(sig) {
556 self.signal_child(signum).await;
557 } else {
558 tracing::debug!(?sig, "ssh: unhandled signal; not forwarding");
559 }
560 }
561 ChannelEvent::Close | ChannelEvent::Eof => {
562 tracing::debug!(channel_id = %self.channel_id, ?event, "ssh: closing shell");
563 self.kill_child().await;
564 }
565 }
566 Ok(())
567 }
568}
569
570#[cfg(all(test, feature = "ssh"))]
571mod tests {
572 use super::*;
573
574 fn fake_user() -> ResolvedUser {
575 ResolvedUser {
576 name: "alice".to_string(),
577 uid: Uid::from_raw(1000),
578 gid: Gid::from_raw(1000),
579 home: PathBuf::from("/home/alice"),
580 shell: PathBuf::from("/bin/bash"),
581 }
582 }
583
584 #[test]
585 fn env_is_minimal_and_correct() {
586 let env = build_env(&fake_user());
587 let get = |k: &str| {
588 env.iter()
589 .find(|(key, _)| key == k)
590 .map(|(_, v)| v.as_str())
591 };
592
593 assert_eq!(get("HOME"), Some("/home/alice"));
594 assert_eq!(get("USER"), Some("alice"));
595 assert_eq!(get("LOGNAME"), Some("alice"));
596 assert_eq!(get("SHELL"), Some("/bin/bash"));
597 assert_eq!(get("TERM"), Some("xterm-256color"));
598 assert_eq!(get("PATH"), Some(DEFAULT_PATH));
599 // No daemon environment leaks through: only the six known keys are present.
600 assert_eq!(env.len(), 6);
601 }
602
603 #[test]
604 fn resolve_unknown_user_fails_closed() {
605 // A username that cannot exist in any passwd database must yield Err, never a shell.
606 let err = resolve_user("definitely-not-a-real-user-xyz")
607 .expect_err("bogus user must fail closed");
608 assert!(matches!(
609 err.kind(),
610 std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
611 ));
612 }
613
614 #[test]
615 fn login_shell_uses_dash_l() {
616 // The interactive path always starts a login shell with `-l`. The exec form
617 // (`<shell> -c <cmd>`) is documented as unsupported because `ChannelEvent` carries no
618 // exec request; see the module note in `Device::listen_ssh`.
619 assert_eq!(LOGIN_SHELL_ARG, "-l");
620 }
621
622 #[test]
623 fn priv_drop_plan_orders_uid_last() {
624 let uid = Uid::from_raw(1000);
625 let gid = Gid::from_raw(1000);
626 // Linux production path includes the supplementary-groups step first.
627 let plan = priv_drop_plan(uid, gid, true);
628 assert_eq!(
629 plan,
630 vec![
631 PrivDropStep::InitGroups(gid),
632 PrivDropStep::SetGid(gid),
633 PrivDropStep::SetUid(uid),
634 ],
635 "drop sequence must be initgroups → setgid → setuid"
636 );
637 // setuid MUST be last — fails loudly if anyone reorders.
638 assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
639 }
640
641 #[test]
642 fn priv_drop_plan_apple_skips_initgroups() {
643 let uid = Uid::from_raw(1000);
644 let gid = Gid::from_raw(1000);
645 // Apple path: `initgroups` is unavailable, so no InitGroups step — but still uid-last.
646 let plan = priv_drop_plan(uid, gid, false);
647 assert_eq!(
648 plan,
649 vec![PrivDropStep::SetGid(gid), PrivDropStep::SetUid(uid)],
650 );
651 assert!(!plan.contains(&PrivDropStep::InitGroups(gid)));
652 assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
653 }
654
655 #[test]
656 fn priv_drop_setgid_before_setuid() {
657 let uid = Uid::from_raw(1000);
658 let gid = Gid::from_raw(1000);
659 // The sacred invariant expressed directly: gid is dropped before uid, on every platform.
660 for with_initgroups in [true, false] {
661 let plan = priv_drop_plan(uid, gid, with_initgroups);
662 let setgid_idx = plan
663 .iter()
664 .position(|s| *s == PrivDropStep::SetGid(gid))
665 .expect("plan must set gid");
666 let setuid_idx = plan
667 .iter()
668 .position(|s| *s == PrivDropStep::SetUid(uid))
669 .expect("plan must set uid");
670 assert!(
671 setgid_idx < setuid_idx,
672 "setgid must precede setuid (with_initgroups={with_initgroups})"
673 );
674 }
675 }
676
677 /// A [`ChannelContext`] with no resolved peer, carrying the facts the cast header needs.
678 fn ctx() -> ChannelContext {
679 ChannelContext {
680 accept: crate::ssh::SshAccept {
681 local_user: "ubuntu".to_string(),
682 accept_env: Vec::new(),
683 session_duration_nanos: None,
684 allow_agent_forwarding: false,
685 allow_local_port_forwarding: false,
686 allow_remote_port_forwarding: false,
687 recorders: Vec::new(),
688 on_recording_failure: None,
689 hold_and_delegate: String::new(),
690 recording_refusal_message: String::new(),
691 },
692 ssh_user: "operator".to_string(),
693 remote: "100.64.0.7:52344".parse().unwrap(),
694 src_node: None,
695 conn_id: "ssh-conn-20231114T221320-0011223344".to_string(),
696 }
697 }
698
699 /// The cast header identifies the session: the username the client asked for, the local user
700 /// it was mapped to, the connection it belongs to, and the terminal type.
701 #[test]
702 fn cast_header_describes_the_session() {
703 let header = session_cast_header(&ctx(), &fake_user());
704 assert_eq!(
705 header.ssh_user, "operator",
706 "the username the client presented"
707 );
708 assert_eq!(
709 header.local_user,
710 fake_user().name,
711 "the local user the policy mapped it to"
712 );
713 assert_eq!(header.connection_id, "ssh-conn-20231114T221320-0011223344");
714 assert_eq!(
715 header.env.get("TERM").map(String::as_str),
716 Some(DEFAULT_TERM)
717 );
718 // No PTY size is known when the handler is built; see `session_cast_header`.
719 assert_eq!((header.width, header.height), (0, 0));
720 // With no resolved peer nothing about the source node is invented.
721 assert!(header.src_node.is_empty());
722 assert!(header.src_node_id.is_empty());
723 assert_eq!(header.src_node_user_id, 0);
724 assert!(header.src_node_tags.is_empty());
725 }
726
727 /// An untagged node contributes its owner id; a tagged node contributes its tags. Never both,
728 /// which is Go's rule.
729 #[test]
730 fn src_node_records_owner_or_tags_never_both() {
731 let mut untagged = CastHeader::new(0, DEFAULT_TERM);
732 set_src_node(
733 &mut untagged,
734 "laptop.tail-scale.ts.net".to_string(),
735 "nodeid-abc".to_string(),
736 &[],
737 42,
738 );
739 assert_eq!(untagged.src_node, "laptop.tail-scale.ts.net");
740 assert_eq!(untagged.src_node_id, "nodeid-abc");
741 assert_eq!(untagged.src_node_user_id, 42);
742 assert!(untagged.src_node_tags.is_empty());
743
744 let mut tagged = CastHeader::new(0, DEFAULT_TERM);
745 set_src_node(
746 &mut tagged,
747 "ci.tail-scale.ts.net".to_string(),
748 "nodeid-def".to_string(),
749 &["tag:ci".to_string()],
750 42,
751 );
752 assert_eq!(tagged.src_node_tags, vec!["tag:ci".to_string()]);
753 assert_eq!(
754 tagged.src_node_user_id, 0,
755 "a tagged node has no human owner to record"
756 );
757 }
758
759 /// The refusal exit status is the one Go reserves for a denied recording-required session.
760 #[test]
761 fn recording_refusal_uses_the_reserved_exit_code() {
762 assert_eq!(RECORDING_DENIED_EXIT_CODE, 254);
763 }
764
765 #[test]
766 fn empty_shell_falls_back_to_default() {
767 // Mirror resolve_user's normalization of an empty passwd shell field.
768 let mut u = fake_user();
769 u.shell = PathBuf::from("");
770 let shell = if u.shell.as_os_str().is_empty() {
771 PathBuf::from(DEFAULT_SHELL)
772 } else {
773 u.shell.clone()
774 };
775 assert_eq!(shell, PathBuf::from(DEFAULT_SHELL));
776 }
777}