#![forbid(unsafe_code)]
use std::{
io::{stdin, stdout},
os::{
fd::{AsFd, AsRawFd, RawFd},
unix::ffi::OsStrExt,
},
process::exit,
sync::atomic::Ordering,
};
use libc::{
c_uint, c_ushort, epoll_event, winsize, STDIN_FILENO, STDOUT_FILENO, TIOCGWINSZ, TIOCSWINSZ,
};
use libseccomp::{scmp_cmp, ScmpAction, ScmpFilterContext};
use nix::{
errno::Errno,
fcntl::{splice, OFlag, SpliceFFlags},
poll::PollTimeout,
sched::CloneFlags,
sys::{
epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags},
resource::{getrlimit, Resource},
signal::{sigprocmask, SigmaskHow, Signal},
signalfd::{SfdFlags, SigSet, SignalFd},
stat::{umask, Mode},
termios::{cfmakeraw, tcgetattr, tcsetattr, LocalFlags, SetArg},
},
unistd::{chdir, isatty, pipe2},
};
use crate::{
compat::{epoll_ctl_safe, set_dumpable, set_name, set_no_new_privs},
config::{PTY_FCNTL_OPS, VDSO_SYSCALLS},
confine::{
confine_mdwe, confine_rlimit, confine_rlimit_zero, confine_scmp_close,
confine_scmp_exit_group, confine_scmp_fcntl, pivot_root_cwd, safe_drop_caps, secure_getenv,
try_unshare, Sydcall, CLONE_NEWTIME,
},
cookie::{safe_exit_group, CookieIdx, SYSCOOKIE_POOL},
eprintfln,
err::SydResult,
fd::{
close, closeexcept, read_signal, set_exclusive, set_nonblock, to_active_fd, SafeOwnedFd,
SIGINFO_SIZE,
},
id::SydId,
ignore_signals,
landlock_policy::LandlockPolicy,
log::LOG_FD,
main, printfln,
pty::{winsize_get, winsize_set},
rng::duprand,
IgnoreSignalOpts,
};
const N_TTY_BUF_SIZE: usize = 4096;
const PIPE_BUF: usize = N_TTY_BUF_SIZE;
main! { pty_bin_main =>
if !isatty(stdin()).unwrap_or(false) {
eprintfln!("syd-pty: Error: Standard input is not a TTY.")?;
return Err(Errno::ENOTTY.into());
}
if !isatty(stdout()).unwrap_or(false) {
eprintfln!("syd-pty: Error: Standard output is not a TTY.")?;
return Err(Errno::ENOTTY.into());
}
let result = pty_bin_run(None);
let code = match result {
Ok(()) => 0,
Err(err) => err.errno().map(|errno| errno as i32).unwrap_or(128),
};
safe_exit_group(code)
}
pub(crate) fn pty_bin_run(opts: Option<PtyBinOpts>) -> SydResult<()> {
let _ = set_name(SydId::get_cname(c"syd-pty"));
safe_drop_caps()?;
set_no_new_privs()?;
if !cfg!(feature = "trusted") {
pty_confine_dump()?;
pty_confine_name()?;
pty_confine_lock()?;
pty_confine_rlim_main()?;
}
ignore_signals(IgnoreSignalOpts::NoWinch)?;
umask(Mode::from_bits_retain(0o777));
let opts = match opts {
Some(opts) => opts,
None => parse_options()?,
};
let PtyBinOpts {
fpty,
ws_x,
ws_y,
is_debug,
} = opts;
#[expect(clippy::cast_sign_loss)]
let fd = fpty.as_raw_fd() as c_uint;
closeexcept(&[0, 1, fd])?;
LOG_FD.store(-42, Ordering::Release);
if let Ok((soft_limit, hard_limit)) = getrlimit(Resource::RLIMIT_NOFILE) {
if soft_limit < hard_limit {
let _ = confine_rlimit(Resource::RLIMIT_NOFILE, Some(hard_limit));
}
}
let fpty_fd = duprand(fpty.as_raw_fd(), OFlag::O_CLOEXEC)?;
drop(fpty);
let fpty = fpty_fd;
let epoll = Epoll::new(EpollCreateFlags::EPOLL_CLOEXEC)?;
let epoll_fd = duprand(epoll.0.as_raw_fd(), OFlag::O_CLOEXEC)?;
drop(epoll);
let epoll = Epoll(epoll_fd.into());
let fsig: Option<SignalFd> = if ws_x.is_some() && ws_y.is_some() {
None
} else {
let mut mask = SigSet::empty();
mask.add(Signal::SIGWINCH);
sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask), None)?;
let fl = SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC;
let fd = SignalFd::with_flags(&mask, fl)?;
Some(duprand(fd.as_raw_fd(), OFlag::O_CLOEXEC)?.into())
};
let (pipe_pty_rd, pipe_pty_wr) = {
let (rd, wr) = pipe2(OFlag::O_NONBLOCK | OFlag::O_CLOEXEC)?;
let rd = duprand(rd.as_raw_fd(), OFlag::O_CLOEXEC)?;
let wr = duprand(wr.as_raw_fd(), OFlag::O_CLOEXEC)?;
(rd, wr)
};
let (pipe_std_rd, pipe_std_wr) = {
let (rd, wr) = pipe2(OFlag::O_NONBLOCK | OFlag::O_CLOEXEC)?;
let rd = duprand(rd.as_raw_fd(), OFlag::O_CLOEXEC)?;
let wr = duprand(wr.as_raw_fd(), OFlag::O_CLOEXEC)?;
(rd, wr)
};
let fstd_rd = duprand(STDIN_FILENO, OFlag::O_CLOEXEC)?;
let fstd_wr = duprand(STDOUT_FILENO, OFlag::O_CLOEXEC)?;
let _ = close(STDIN_FILENO);
let _ = close(STDOUT_FILENO);
set_exclusive(&fpty, true)?;
set_nonblock(&fpty, true)?;
set_nonblock(&fstd_rd, true)?;
set_nonblock(&fstd_wr, true)?;
refresh_pty(&fstd_rd, &fpty)?;
refresh_win(&fstd_rd, &fpty, ws_x, ws_y);
if !cfg!(feature = "trusted") {
pty_confine_rlim_file()?;
} else if !is_debug {
pty_confine_dump()?;
pty_confine_name()?;
pty_confine_lock()?;
pty_confine_rlim_main()?;
pty_confine_rlim_file()?;
}
if !is_debug {
let ctx = pty_confine_scmp(fsig.as_ref(), &fstd_rd, &fpty)?;
ctx.load()?;
}
let result = pty_bin_fwd(
&epoll,
fsig.as_ref(),
fpty,
(fstd_rd, fstd_wr),
(pipe_pty_rd, pipe_pty_wr),
(pipe_std_rd, pipe_std_wr),
(ws_x, ws_y),
);
drop(SafeOwnedFd::from(epoll.0));
drop(fsig.map(SafeOwnedFd::from));
result
}
fn pty_bin_fwd<Fd1, Fd2, Fd3, Fd4, Fd5, Fd6, Fd7>(
epoll: &Epoll,
sig_fd: Option<&SignalFd>,
pty_fd: Fd1,
std_fd: (Fd2, Fd3),
pipe_pty: (Fd4, Fd5),
pipe_std: (Fd6, Fd7),
win_sz: (Option<c_ushort>, Option<c_ushort>),
) -> SydResult<()>
where
Fd1: AsFd,
Fd2: AsFd,
Fd3: AsFd,
Fd4: AsFd,
Fd5: AsFd,
Fd6: AsFd,
Fd7: AsFd,
{
let (std_rd, std_wr) = std_fd;
let (pipe_pty_rd, pipe_pty_wr) = pipe_pty;
let (pipe_std_rd, pipe_std_wr) = pipe_std;
let (ws_x, ws_y) = win_sz;
#[expect(clippy::cast_sign_loss)]
let event = epoll_event {
events: (EpollFlags::EPOLLET
| EpollFlags::EPOLLIN
| EpollFlags::EPOLLOUT
| EpollFlags::EPOLLRDHUP)
.bits() as u32,
u64: pty_fd.as_fd().as_raw_fd() as u64,
};
epoll_ctl_safe(&epoll.0, pty_fd.as_fd().as_raw_fd(), Some(event))?;
#[expect(clippy::cast_sign_loss)]
let event = epoll_event {
events: (EpollFlags::EPOLLET | EpollFlags::EPOLLIN | EpollFlags::EPOLLRDHUP).bits() as u32,
u64: std_rd.as_fd().as_raw_fd() as u64,
};
epoll_ctl_safe(&epoll.0, std_rd.as_fd().as_raw_fd(), Some(event))?;
#[expect(clippy::cast_sign_loss)]
let event = epoll_event {
events: (EpollFlags::EPOLLET | EpollFlags::EPOLLOUT | EpollFlags::EPOLLRDHUP).bits() as u32,
u64: std_wr.as_fd().as_raw_fd() as u64,
};
epoll_ctl_safe(&epoll.0, std_wr.as_fd().as_raw_fd(), Some(event))?;
if let Some(sig_fd) = sig_fd {
#[expect(clippy::cast_sign_loss)]
let event = epoll_event {
events: (EpollFlags::EPOLLET | EpollFlags::EPOLLIN | EpollFlags::EPOLLRDHUP).bits()
as u32,
u64: sig_fd.as_fd().as_raw_fd() as u64,
};
epoll_ctl_safe(&epoll.0, sig_fd.as_fd().as_raw_fd(), Some(event))?;
}
let mut events = [EpollEvent::empty(); 1024];
loop {
let n = match epoll.wait(&mut events, PollTimeout::NONE) {
Ok(n) => n,
Err(Errno::EINTR) => continue, Err(errno) => return Err(errno.into()),
};
'eventloop: for event in events.iter().take(n) {
let fd = event.data() as RawFd;
let mut event_flags = event.events();
let is_inp = event_flags
.contains(EpollFlags::EPOLLIN)
.then(|| event_flags.remove(EpollFlags::EPOLLIN))
.is_some();
let is_out = event_flags
.contains(EpollFlags::EPOLLOUT)
.then(|| event_flags.remove(EpollFlags::EPOLLOUT))
.is_some();
let is_err = !event_flags.is_empty();
if let Some(sig_fd) = sig_fd.filter(|sfd| is_inp && fd == sfd.as_raw_fd()) {
loop {
let sig_info = match read_signal(sig_fd) {
Ok(sig_info) => {
sig_info
}
Err(Errno::EAGAIN) => {
continue 'eventloop;
}
Err(Errno::EINTR) => continue,
Err(errno) => return Err(errno.into()),
};
#[expect(clippy::cast_possible_wrap)]
if sig_info.ssi_signo as i32 == Signal::SIGWINCH as i32 {
refresh_win(&std_rd, &pty_fd, ws_x, ws_y);
}
}
}
if is_inp {
if fd == std_rd.as_fd().as_raw_fd() {
if splice_move(&std_rd, &pty_fd, &pipe_pty_rd, &pipe_pty_wr)? {
splice_pipe(&pipe_pty_rd, &pty_fd)?;
return Ok(());
}
} else if fd == pty_fd.as_fd().as_raw_fd() {
splice_move(&pty_fd, &std_wr, &pipe_std_rd, &pipe_std_wr)?;
}
}
if is_out {
if fd == std_wr.as_fd().as_raw_fd() {
splice_pipe(&pipe_std_rd, &std_wr)?;
splice_move(&pty_fd, &std_wr, &pipe_std_rd, &pipe_std_wr)?;
} else if fd == pty_fd.as_fd().as_raw_fd() {
splice_pipe(&pipe_pty_rd, &pty_fd)?;
splice_move(&std_rd, &pty_fd, &pipe_pty_rd, &pipe_pty_wr)?;
}
}
if is_err {
if fd == std_wr.as_fd().as_raw_fd() {
splice_pipe(&pipe_pty_rd, &pty_fd)?;
} else if fd == pty_fd.as_fd().as_raw_fd() {
set_nonblock(&std_wr, false)?;
splice_pipe(&pipe_std_rd, &std_wr)?;
splice_move(&pty_fd, &std_wr, &pipe_std_rd, &pipe_std_wr)?;
return Ok(());
} else if fd == std_rd.as_fd().as_raw_fd() {
splice_pipe(&pipe_pty_rd, &pty_fd)?;
return Ok(());
}
}
}
}
}
fn pty_confine_dump() -> Result<(), Errno> {
std::panic::set_hook(Box::new(|_| {}));
set_dumpable(false)?;
let _ = confine_mdwe(false);
Ok(())
}
fn pty_confine_rlim_main() -> Result<(), Errno> {
confine_rlimit_zero(&[
Resource::RLIMIT_CORE,
Resource::RLIMIT_FSIZE,
Resource::RLIMIT_NPROC,
Resource::RLIMIT_LOCKS,
Resource::RLIMIT_MEMLOCK,
Resource::RLIMIT_MSGQUEUE,
])
}
fn pty_confine_rlim_file() -> Result<(), Errno> {
confine_rlimit_zero(&[Resource::RLIMIT_NOFILE])
}
fn pty_confine_lock() -> SydResult<()> {
let abi = crate::landlock::ABI::new_current();
let policy = LandlockPolicy::default();
let _ = policy.restrict_self(abi);
Ok(())
}
fn pty_confine_name() -> SydResult<()> {
chdir("/proc/self/fdinfo")?;
let namespaces = CloneFlags::CLONE_NEWUSER
| CloneFlags::CLONE_NEWNS
| CloneFlags::CLONE_NEWUTS
| CloneFlags::CLONE_NEWIPC
| CloneFlags::CLONE_NEWPID
| CloneFlags::CLONE_NEWNET
| CloneFlags::CLONE_NEWCGROUP
| CLONE_NEWTIME;
let namespaces = try_unshare(namespaces)?;
if namespaces.contains(CloneFlags::CLONE_NEWNS) {
pivot_root_cwd()?; }
if namespaces.contains(CloneFlags::CLONE_NEWUSER) {
safe_drop_caps()?;
}
Ok(())
}
fn pty_confine_scmp<Fd1, Fd2, Fd3>(
sig_fd: Option<Fd1>,
std_fd: Fd2,
pty_fd: Fd3,
) -> SydResult<ScmpFilterContext>
where
Fd1: AsFd,
Fd2: AsFd,
Fd3: AsFd,
{
SYSCOOKIE_POOL.init()?;
let mut ctx = new_filter(ScmpAction::KillProcess)?;
let allow_call = [
"splice",
"epoll_ctl",
"epoll_wait",
"epoll_pwait",
"epoll_pwait2",
];
for name in allow_call.iter().chain(VDSO_SYSCALLS) {
if let Ok(syscall) = Sydcall::from_name(name) {
ctx.add_rule(ScmpAction::Allow, syscall)?;
}
}
confine_scmp_fcntl(&mut ctx, PTY_FCNTL_OPS)?;
confine_scmp_close(&mut ctx, true )?;
confine_scmp_exit_group(&mut ctx, true )?;
if let Some(sig_fd) = sig_fd {
pty_confine_scmp_sig(&mut ctx, sig_fd, &std_fd, &pty_fd)?;
}
#[cfg(libseccomp_v2_6)]
ctx.precompute()?;
Ok(ctx)
}
fn pty_confine_scmp_sig<Fd1, Fd2, Fd3>(
ctx: &mut ScmpFilterContext,
sig_fd: Fd1,
std_fd: Fd2,
pty_fd: Fd3,
) -> SydResult<()>
where
Fd1: AsFd,
Fd2: AsFd,
Fd3: AsFd,
{
#[expect(clippy::disallowed_methods)]
let syscall = Sydcall::from_name("read").unwrap();
#[expect(clippy::cast_sign_loss)]
#[expect(clippy::useless_conversion)]
ctx.add_rule_conditional(
ScmpAction::Allow,
syscall,
&[
scmp_cmp!($arg0 == sig_fd.as_fd().as_raw_fd() as u64),
scmp_cmp!($arg2 == SIGINFO_SIZE as u64),
scmp_cmp!($arg3 == SYSCOOKIE_POOL.try_get(CookieIdx::ReadArg3)?.into()),
scmp_cmp!($arg4 == SYSCOOKIE_POOL.try_get(CookieIdx::ReadArg4)?.into()),
scmp_cmp!($arg5 == SYSCOOKIE_POOL.try_get(CookieIdx::ReadArg5)?.into()),
],
)?;
#[expect(clippy::disallowed_methods)]
let syscall = Sydcall::from_name("ioctl").unwrap();
#[expect(clippy::cast_sign_loss)]
#[expect(clippy::unnecessary_cast)]
{
ctx.add_rule_conditional(
ScmpAction::Allow,
syscall,
&[
scmp_cmp!($arg0 == std_fd.as_fd().as_raw_fd() as u64),
scmp_cmp!($arg1 & 0xFFFFFFFF == TIOCGWINSZ as u64),
],
)?;
ctx.add_rule_conditional(
ScmpAction::Allow,
syscall,
&[
scmp_cmp!($arg0 == pty_fd.as_fd().as_raw_fd() as u64),
scmp_cmp!($arg1 & 0xFFFFFFFF == TIOCSWINSZ as u64),
],
)?;
}
Ok(())
}
fn new_filter(action: ScmpAction) -> SydResult<ScmpFilterContext> {
let mut filter = ScmpFilterContext::new(action)?;
filter.set_ctl_nnp(true)?;
filter.set_act_badarch(ScmpAction::KillProcess)?;
let _ = filter.set_ctl_optimize(2);
Ok(filter)
}
fn splice_data<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<usize, Errno> {
match splice(
src,
None,
dst,
None,
PIPE_BUF,
SpliceFFlags::SPLICE_F_NONBLOCK | SpliceFFlags::SPLICE_F_MORE,
) {
Err(Errno::EINVAL | Errno::EIO) => Ok(0), result => result,
}
}
fn splice_pipe<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<(), Errno> {
loop {
return match splice_data(&src, &dst) {
Ok(0) | Err(Errno::EAGAIN) => Ok(()),
Ok(_) | Err(Errno::EINTR) => continue,
Err(errno) => Err(errno),
};
}
}
fn splice_move<Fd1: AsFd, Fd2: AsFd, Fd3: AsFd, Fd4: AsFd>(
src: Fd1,
dst: Fd2,
pipe_rd: Fd3,
pipe_wr: Fd4,
) -> Result<bool, Errno> {
loop {
match splice_data(&src, &pipe_wr) {
Ok(0) => return Ok(true),
Ok(_) => splice_pipe(&pipe_rd, &dst)?,
Err(Errno::EINTR) => {}
Err(Errno::EAGAIN) => return Ok(false),
Err(errno) => return Err(errno),
}
}
}
fn refresh_win<Fd1: AsFd, Fd2: AsFd>(
src: Fd1,
dst: Fd2,
ws_x: Option<c_ushort>,
ws_y: Option<c_ushort>,
) {
if let Some(ws_row) = ws_x {
if let Some(ws_col) = ws_y {
let ws = winsize {
ws_row,
ws_col,
ws_xpixel: 0,
ws_ypixel: 0,
};
let _ = winsize_set(&dst, ws);
return;
}
}
if let Ok(mut ws) = winsize_get(&src) {
if let Some(ws_row) = ws_x {
ws.ws_row = ws_row;
}
if let Some(ws_col) = ws_y {
ws.ws_col = ws_col;
}
let _ = winsize_set(&dst, ws);
}
}
#[expect(clippy::disallowed_methods)]
fn refresh_pty<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<(), Errno> {
let mut tio = tcgetattr(&src)?;
tcsetattr(&dst, SetArg::TCSANOW, &tio)?;
cfmakeraw(&mut tio);
tio.local_flags.insert(LocalFlags::TOSTOP);
tcsetattr(&src, SetArg::TCSANOW, &tio)?;
Ok(())
}
pub(crate) struct PtyBinOpts {
pub(crate) fpty: SafeOwnedFd,
pub(crate) ws_x: Option<c_ushort>,
pub(crate) ws_y: Option<c_ushort>,
pub(crate) is_debug: bool,
}
fn parse_options() -> SydResult<PtyBinOpts> {
use lexopt::prelude::*;
let mut opt_fpty = None;
let mut opt_ws_x = None;
let mut opt_ws_y = None;
let mut opt_debug = secure_getenv("SYD_PTY_DEBUG").is_some();
let mut parser = lexopt::Parser::from_env();
while let Some(arg) = parser.next()? {
match arg {
Short('h') => {
help()?;
exit(0);
}
Short('i') => opt_fpty = Some(parser.value()?),
Short('x') => opt_ws_x = Some(parser.value()?.parse::<String>()?.parse::<c_ushort>()?),
Short('y') => opt_ws_y = Some(parser.value()?.parse::<String>()?.parse::<c_ushort>()?),
Short('d') if cfg!(feature = "trusted") => opt_debug = true,
Short('d') => {
eprintfln!("syd-pty: Error: -d option isn't permitted.")?;
eprintfln!("syd-pty: Syd isn't built with trusted feature.")?;
return Err(Errno::EPERM.into());
}
_ => return Err(arg.unexpected().into()),
}
}
let fpty = if let Some(fpty) = opt_fpty {
to_active_fd(fpty.as_bytes())?
} else {
eprintfln!("syd-pty: Error: -i is required.")?;
help()?;
exit(1);
};
Ok(PtyBinOpts {
fpty,
ws_x: opt_ws_x,
ws_y: opt_ws_y,
is_debug: opt_debug,
})
}
fn help() -> Result<(), Errno> {
printfln!("Usage: syd-pty [-dh] -i <pty-fd> [-x x-size] [-y y-size]")?;
printfln!("Syd's PTY to STDIO bidirectional forwarder")?;
printfln!("Forwards data between the given pty(7) main file descriptor and stdio(3).")?;
printfln!(" -h Print this help message and exit.")?;
printfln!(" -i <pty-fd> PTY main file descriptor.")?;
printfln!(" -x <x-size> Specify window row size (default: inherit).")?;
printfln!(" -y <y-size> Specify window column size (default: inherit).")?;
printfln!(" -d Run in debug mode without confinement.")?;
printfln!(" This requires Syd built with trusted feature.")?;
Ok(())
}