use std::{future::Future, path::PathBuf, sync::Arc};
use nix::unistd::{Gid, Uid, User};
use pty_process::{OwnedWritePty, Size};
use russh::{ChannelId, Sig, server::Handle};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::Mutex,
};
use crate::{
Device,
ssh::{
ChannelContext, ChannelEvent, ChannelHandler,
recording::{CastHeader, RecordingRejected, SessionRecording, TailnetDialer},
},
};
const DEFAULT_SHELL: &str = "/bin/sh";
const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
#[derive(Debug, Clone)]
struct ResolvedUser {
name: String,
uid: Uid,
gid: Gid,
home: PathBuf,
shell: PathBuf,
}
fn resolve_user(local_user: &str) -> std::io::Result<ResolvedUser> {
match User::from_name(local_user) {
Ok(Some(user)) => {
let shell = if user.shell.as_os_str().is_empty() {
PathBuf::from(DEFAULT_SHELL)
} else {
user.shell
};
Ok(ResolvedUser {
name: user.name,
uid: user.uid,
gid: user.gid,
home: user.dir,
shell,
})
}
Ok(None) => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("ssh: local user {local_user:?} not found in passwd database"),
)),
Err(e) => Err(std::io::Error::other(format!(
"ssh: resolving local user {local_user:?} failed: {e}"
))),
}
}
fn build_env(user: &ResolvedUser) -> Vec<(String, String)> {
vec![
("HOME".to_string(), user.home.to_string_lossy().into_owned()),
("USER".to_string(), user.name.clone()),
("LOGNAME".to_string(), user.name.clone()),
(
"SHELL".to_string(),
user.shell.to_string_lossy().into_owned(),
),
("PATH".to_string(), DEFAULT_PATH.to_string()),
("TERM".to_string(), DEFAULT_TERM.to_string()),
]
}
const LOGIN_SHELL_ARG: &str = "-l";
const DEFAULT_TERM: &str = "xterm-256color";
const RECORDING_DENIED_EXIT_CODE: u32 = 254;
fn session_cast_header(ctx: &ChannelContext, user: &ResolvedUser) -> CastHeader {
let mut header = CastHeader::new(crate::ssh::now_unix_secs(), DEFAULT_TERM);
header.ssh_user = ctx.ssh_user.clone();
header.local_user = user.name.clone();
header.connection_id = ctx.conn_id.clone();
if let Some(node) = &ctx.src_node {
set_src_node(
&mut header,
node.fqdn(false),
node.stable_id.0.clone(),
&node.tags,
node.user_id,
);
}
header
}
fn set_src_node(
header: &mut CastHeader,
fqdn: String,
stable_id: String,
tags: &[String],
user_id: i64,
) {
header.src_node = fqdn;
header.src_node_id = stable_id;
if tags.is_empty() {
header.src_node_user_id = user_id;
} else {
header.src_node_tags = tags.to_vec();
}
}
trait RefusalSink {
fn channel(&self) -> u32;
fn send_message(&self, message: String) -> impl Future<Output = bool> + Send;
fn send_exit_status(&self, status: u32) -> impl Future<Output = bool> + Send;
fn close(&self) -> impl Future<Output = bool> + Send;
}
struct ChannelRefusal<'a> {
session: &'a Handle,
channel_id: ChannelId,
}
impl RefusalSink for ChannelRefusal<'_> {
fn channel(&self) -> u32 {
self.channel_id.number()
}
async fn send_message(&self, message: String) -> bool {
self.session
.data(self.channel_id, message.into_bytes())
.await
.is_ok()
}
async fn send_exit_status(&self, status: u32) -> bool {
self.session
.exit_status_request(self.channel_id, status)
.await
.is_ok()
}
async fn close(&self) -> bool {
self.session.close(self.channel_id).await.is_ok()
}
}
async fn reject_session<S: RefusalSink>(sink: &S, rejected: RecordingRejected) {
let channel_id = sink.channel();
tracing::warn!(
%channel_id,
error = %rejected.cause,
message = %rejected.message,
"ssh: session refused: session recording could not be started"
);
let message_sent = sink.send_message(format!("{}\r\n", rejected.message)).await;
let status_sent = sink.send_exit_status(RECORDING_DENIED_EXIT_CODE).await;
let closed = sink.close().await;
if !(message_sent && status_sent && closed) {
tracing::debug!(
%channel_id,
message_sent,
status_sent,
closed,
"ssh: client gone before the refusal reached it"
);
}
}
async fn end_session(
session: &Handle,
channel_id: ChannelId,
child: &Arc<Mutex<tokio::process::Child>>,
message: &str,
) {
tracing::warn!(%channel_id, message, "ssh: terminating session: session recording failed");
if session
.data(channel_id, format!("\r\n{message}\r\n").into_bytes())
.await
.is_err()
{
tracing::debug!(%channel_id, "ssh: client gone before the termination notice reached it");
}
if let Err(e) = child.lock().await.start_kill() {
tracing::debug!(error = %e, %channel_id, "ssh: failed to kill shell after recording failure");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PrivDropStep {
InitGroups(Gid),
SetGid(Gid),
SetUid(Uid),
}
fn priv_drop_plan(uid: Uid, gid: Gid, with_initgroups: bool) -> Vec<PrivDropStep> {
let mut plan = Vec::with_capacity(3);
if with_initgroups {
plan.push(PrivDropStep::InitGroups(gid));
}
plan.push(PrivDropStep::SetGid(gid));
plan.push(PrivDropStep::SetUid(uid));
plan
}
fn apply_priv_drop_step(
step: &PrivDropStep,
user_cname: Option<&std::ffi::CStr>,
) -> std::io::Result<()> {
match step {
PrivDropStep::InitGroups(gid) => {
#[cfg(not(target_vendor = "apple"))]
{
let cname = user_cname.ok_or_else(|| {
std::io::Error::other("ssh: initgroups step without user name")
})?;
nix::unistd::initgroups(cname, *gid)
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
#[cfg(target_vendor = "apple")]
{
let _ = (gid, user_cname);
}
}
PrivDropStep::SetGid(gid) => {
nix::unistd::setgid(*gid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
PrivDropStep::SetUid(uid) => {
nix::unistd::setuid(*uid).map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
}
}
Ok(())
}
pub struct ShellHandler {
channel_id: ChannelId,
pty_write: OwnedWritePty,
child: Arc<Mutex<tokio::process::Child>>,
}
impl ShellHandler {
async fn signal_child(&self, signum: i32) {
let pid = { self.child.lock().await.id() };
let Some(pid) = pid else {
return;
};
let Ok(signal) = nix::sys::signal::Signal::try_from(signum) else {
tracing::debug!(signum, "ssh: unmapped signal; not forwarding");
return;
};
if let Err(e) =
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as nix::libc::pid_t), signal)
{
tracing::debug!(error = %e, signum, "ssh: failed forwarding signal to shell");
}
}
async fn kill_child(&self) {
let mut child = self.child.lock().await;
if let Err(e) = child.start_kill() {
tracing::debug!(error = %e, "ssh: failed to kill shell child");
}
}
}
fn sig_to_signum(sig: &Sig) -> Option<i32> {
Some(match sig {
Sig::HUP => nix::libc::SIGHUP,
Sig::INT => nix::libc::SIGINT,
Sig::QUIT => nix::libc::SIGQUIT,
Sig::KILL => nix::libc::SIGKILL,
Sig::TERM => nix::libc::SIGTERM,
_ => return None,
})
}
impl ChannelHandler for ShellHandler {
type Error = std::io::Error;
const RECORDS_SESSION: bool = true;
async fn new(
rt: tokio::runtime::Handle,
channel_id: ChannelId,
session: Handle,
dev: Arc<Device>,
ctx: &ChannelContext,
) -> Result<Self, Self::Error> {
let accept = &ctx.accept;
let user = resolve_user(&accept.local_user)?;
let env = build_env(&user);
let recording = if accept.recorders.is_empty() {
None
} else {
let header = session_cast_header(ctx, &user);
match SessionRecording::start(
&accept.recorders,
accept.on_recording_failure.as_ref(),
&header,
&TailnetDialer::new(dev),
)
.await
{
Ok(rec) => rec,
Err(rejected) => {
reject_session(
&ChannelRefusal {
session: &session,
channel_id,
},
rejected,
)
.await;
return Err(std::io::Error::other("ssh: session recording refused"));
}
}
};
let (pty, pts) = pty_process::open().map_err(std::io::Error::other)?;
#[cfg(not(target_vendor = "apple"))]
let with_initgroups = true;
#[cfg(target_vendor = "apple")]
let with_initgroups = false;
let plan = priv_drop_plan(user.uid, user.gid, with_initgroups);
#[cfg(not(target_vendor = "apple"))]
let user_cname = std::ffi::CString::new(user.name.clone())
.map_err(|e| std::io::Error::other(format!("ssh: user name has NUL byte: {e}")))?;
let mut cmd = pty_process::Command::new(&user.shell);
cmd = cmd.arg(LOGIN_SHELL_ARG).current_dir(&user.home).env_clear();
for (k, v) in env {
cmd = cmd.env(k, v);
}
cmd = unsafe {
cmd.pre_exec(move || {
#[cfg(not(target_vendor = "apple"))]
let user_cname = Some(user_cname.as_c_str());
#[cfg(target_vendor = "apple")]
let user_cname: Option<&std::ffi::CStr> = None;
for step in &plan {
apply_priv_drop_step(step, user_cname)?;
}
Ok(())
})
};
let child = cmd.spawn(pts).map_err(std::io::Error::other)?;
let (mut pty_read, pty_write) = pty.into_split();
let child = Arc::new(Mutex::new(child));
let pump_child = child.clone();
rt.spawn(async move {
let mut buf = [0u8; 16 * 1024];
let mut recording = recording;
let mut terminate = recording.as_mut().and_then(|r| r.take_terminate());
loop {
let read = tokio::select! {
message = async {
match terminate.as_mut() {
Some(rx) => rx.await.ok(),
None => std::future::pending().await,
}
} => {
terminate = None;
if let Some(message) = message {
end_session(&session, channel_id, &pump_child, &message).await;
break;
}
continue;
}
read = pty_read.read(&mut buf) => read,
};
match read {
Ok(0) => break,
Ok(n) => {
if let Some(rec) = recording.as_mut()
&& let Err(message) = rec.record_output(&buf[..n]).await
{
end_session(&session, channel_id, &pump_child, &message).await;
break;
}
if session.data(channel_id, buf[..n].to_vec()).await.is_err() {
tracing::debug!(%channel_id, "ssh: client gone; stopping shell pump");
break;
}
}
Err(e) => {
tracing::debug!(error = %e, %channel_id, "ssh: pty read error");
break;
}
}
}
let status = { pump_child.lock().await.wait().await };
match status {
Ok(status) => {
use std::os::unix::process::ExitStatusExt as _;
let code = status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(0))
as u32;
if session.exit_status_request(channel_id, code).await.is_err() {
tracing::debug!(%channel_id, "ssh: failed sending exit-status");
}
}
Err(e) => {
tracing::debug!(error = %e, %channel_id, "ssh: waiting on shell child");
}
}
if session.close(channel_id).await.is_err() {
tracing::trace!(%channel_id, "ssh: channel already closed");
}
});
Ok(Self {
channel_id,
pty_write,
child,
})
}
async fn handle_event(&mut self, event: &ChannelEvent) -> Result<(), Self::Error> {
match event {
ChannelEvent::Data(bytes) => {
self.pty_write.write_all(bytes).await?;
self.pty_write.flush().await?;
}
ChannelEvent::Resize { width, height } => {
if let Err(e) = self.pty_write.resize(Size::new(*height, *width)) {
tracing::debug!(error = %e, channel_id = %self.channel_id, "ssh: pty resize");
}
}
ChannelEvent::Signal(sig) => {
if let Some(signum) = sig_to_signum(sig) {
self.signal_child(signum).await;
} else {
tracing::debug!(?sig, "ssh: unhandled signal; not forwarding");
}
}
ChannelEvent::Close | ChannelEvent::Eof => {
tracing::debug!(channel_id = %self.channel_id, ?event, "ssh: closing shell");
self.kill_child().await;
}
}
Ok(())
}
}
#[cfg(all(test, feature = "ssh"))]
mod tests {
use super::*;
fn fake_user() -> ResolvedUser {
ResolvedUser {
name: "alice".to_string(),
uid: Uid::from_raw(1000),
gid: Gid::from_raw(1000),
home: PathBuf::from("/home/alice"),
shell: PathBuf::from("/bin/bash"),
}
}
#[test]
fn env_is_minimal_and_correct() {
let env = build_env(&fake_user());
let get = |k: &str| {
env.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(get("HOME"), Some("/home/alice"));
assert_eq!(get("USER"), Some("alice"));
assert_eq!(get("LOGNAME"), Some("alice"));
assert_eq!(get("SHELL"), Some("/bin/bash"));
assert_eq!(get("TERM"), Some("xterm-256color"));
assert_eq!(get("PATH"), Some(DEFAULT_PATH));
assert_eq!(env.len(), 6);
}
#[test]
fn resolve_unknown_user_fails_closed() {
let err = resolve_user("definitely-not-a-real-user-xyz")
.expect_err("bogus user must fail closed");
assert!(matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
));
}
#[test]
fn login_shell_uses_dash_l() {
assert_eq!(LOGIN_SHELL_ARG, "-l");
}
#[test]
fn priv_drop_plan_orders_uid_last() {
let uid = Uid::from_raw(1000);
let gid = Gid::from_raw(1000);
let plan = priv_drop_plan(uid, gid, true);
assert_eq!(
plan,
vec![
PrivDropStep::InitGroups(gid),
PrivDropStep::SetGid(gid),
PrivDropStep::SetUid(uid),
],
"drop sequence must be initgroups → setgid → setuid"
);
assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
}
#[test]
fn priv_drop_plan_apple_skips_initgroups() {
let uid = Uid::from_raw(1000);
let gid = Gid::from_raw(1000);
let plan = priv_drop_plan(uid, gid, false);
assert_eq!(
plan,
vec![PrivDropStep::SetGid(gid), PrivDropStep::SetUid(uid)],
);
assert!(!plan.contains(&PrivDropStep::InitGroups(gid)));
assert_eq!(plan.last(), Some(&PrivDropStep::SetUid(uid)));
}
#[test]
fn priv_drop_setgid_before_setuid() {
let uid = Uid::from_raw(1000);
let gid = Gid::from_raw(1000);
for with_initgroups in [true, false] {
let plan = priv_drop_plan(uid, gid, with_initgroups);
let setgid_idx = plan
.iter()
.position(|s| *s == PrivDropStep::SetGid(gid))
.expect("plan must set gid");
let setuid_idx = plan
.iter()
.position(|s| *s == PrivDropStep::SetUid(uid))
.expect("plan must set uid");
assert!(
setgid_idx < setuid_idx,
"setgid must precede setuid (with_initgroups={with_initgroups})"
);
}
}
fn ctx() -> ChannelContext {
ChannelContext {
accept: crate::ssh::SshAccept {
local_user: "ubuntu".to_string(),
accept_env: Vec::new(),
session_duration_nanos: None,
allow_agent_forwarding: false,
allow_local_port_forwarding: false,
allow_remote_port_forwarding: false,
recorders: Vec::new(),
on_recording_failure: None,
hold_and_delegate: String::new(),
recording_refusal_message: String::new(),
},
ssh_user: "operator".to_string(),
remote: "100.64.0.7:52344".parse().unwrap(),
src_node: None,
conn_id: "ssh-conn-20231114T221320-0011223344".to_string(),
}
}
#[test]
fn cast_header_describes_the_session() {
let header = session_cast_header(&ctx(), &fake_user());
assert_eq!(
header.ssh_user, "operator",
"the username the client presented"
);
assert_eq!(
header.local_user,
fake_user().name,
"the local user the policy mapped it to"
);
assert_eq!(header.connection_id, "ssh-conn-20231114T221320-0011223344");
assert_eq!(
header.env.get("TERM").map(String::as_str),
Some(DEFAULT_TERM)
);
assert_eq!((header.width, header.height), (0, 0));
assert!(header.src_node.is_empty());
assert!(header.src_node_id.is_empty());
assert_eq!(header.src_node_user_id, 0);
assert!(header.src_node_tags.is_empty());
}
#[test]
fn src_node_records_owner_or_tags_never_both() {
let mut untagged = CastHeader::new(0, DEFAULT_TERM);
set_src_node(
&mut untagged,
"laptop.tail-scale.ts.net".to_string(),
"nodeid-abc".to_string(),
&[],
42,
);
assert_eq!(untagged.src_node, "laptop.tail-scale.ts.net");
assert_eq!(untagged.src_node_id, "nodeid-abc");
assert_eq!(untagged.src_node_user_id, 42);
assert!(untagged.src_node_tags.is_empty());
let mut tagged = CastHeader::new(0, DEFAULT_TERM);
set_src_node(
&mut tagged,
"ci.tail-scale.ts.net".to_string(),
"nodeid-def".to_string(),
&["tag:ci".to_string()],
42,
);
assert_eq!(tagged.src_node_tags, vec!["tag:ci".to_string()]);
assert_eq!(
tagged.src_node_user_id, 0,
"a tagged node has no human owner to record"
);
}
#[test]
fn recording_refusal_uses_the_reserved_exit_code() {
assert_eq!(RECORDING_DENIED_EXIT_CODE, 254);
}
#[derive(Default)]
struct FakeRefusal {
message_fails: bool,
steps: std::sync::Mutex<Vec<String>>,
}
impl FakeRefusal {
fn steps(&self) -> Vec<String> {
self.steps.lock().expect("steps mutex").clone()
}
}
impl RefusalSink for FakeRefusal {
fn channel(&self) -> u32 {
7
}
async fn send_message(&self, message: String) -> bool {
self.steps
.lock()
.expect("steps mutex")
.push(format!("message:{message:?}"));
!self.message_fails
}
async fn send_exit_status(&self, status: u32) -> bool {
self.steps
.lock()
.expect("steps mutex")
.push(format!("exit-status:{status}"));
true
}
async fn close(&self) -> bool {
self.steps.lock().expect("steps mutex").push("close".into());
true
}
}
fn rejection() -> RecordingRejected {
RecordingRejected {
message: "this session must be recorded".to_string(),
cause: crate::ssh::recording::RecorderError::NoRecorders,
}
}
#[tokio::test]
async fn refusal_writes_message_then_exit_status_then_close() {
let sink = FakeRefusal::default();
reject_session(&sink, rejection()).await;
assert_eq!(
sink.steps(),
vec![
"message:\"this session must be recorded\\r\\n\"".to_string(),
"exit-status:254".to_string(),
"close".to_string(),
],
);
}
#[tokio::test]
async fn refusal_sends_exit_status_even_when_the_message_write_fails() {
let sink = FakeRefusal {
message_fails: true,
..FakeRefusal::default()
};
reject_session(&sink, rejection()).await;
assert_eq!(
sink.steps(),
vec![
"message:\"this session must be recorded\\r\\n\"".to_string(),
"exit-status:254".to_string(),
"close".to_string(),
],
"a failed message write must not short-circuit the exit status or the close",
);
}
#[test]
fn empty_shell_falls_back_to_default() {
let mut u = fake_user();
u.shell = PathBuf::from("");
let shell = if u.shell.as_os_str().is_empty() {
PathBuf::from(DEFAULT_SHELL)
} else {
u.shell.clone()
};
assert_eq!(shell, PathBuf::from(DEFAULT_SHELL));
}
}