use std::{
env,
os::fd::{AsFd, AsRawFd, FromRawFd, RawFd},
process::{exit, ExitCode},
};
use libseccomp::{scmp_cmp, ScmpAction, ScmpFilterContext};
use nix::{
errno::Errno,
fcntl::{fcntl, splice, FcntlArg, OFlag, SpliceFFlags},
poll::PollTimeout,
sched::{unshare, CloneFlags},
sys::{
epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags},
resource::Resource,
signal::{signal, sigprocmask, SigHandler, SigmaskHow, Signal},
signalfd::{SfdFlags, SigSet, SignalFd},
termios::{cfmakeraw, tcgetattr, tcsetattr, LocalFlags, OutputFlags, SetArg},
},
unistd::{chdir, chroot, pipe2},
};
use crate::{
compat::{epoll_ctl_safe, set_dumpable, set_name, set_no_new_privs},
config::{ALLOC_SYSCALLS, PTY_FCNTL_OPS, PTY_PRCTL_OPS, VDSO_SYSCALLS},
confine::{
confine_landlock_scope, confine_mdwe, confine_rlimit_zero, confine_scmp_fcntl,
confine_scmp_madvise, confine_scmp_prctl, confine_scmp_wx_syd, safe_drop_caps,
secure_getenv, Sydcall, CLONE_NEWTIME,
},
err::SydResult,
fd::{close, closeexcept, set_exclusive, set_nonblock, SafeOwnedFd},
id::SydId,
ignore_signals,
landlock::{AccessFs, AccessNet},
landlock_policy::LandlockPolicy,
main,
pty::{winsize_get, winsize_set},
rng::duprand,
IgnoreSignalOpts,
};
const N_TTY_BUF_SIZE: usize = 4096;
const PIPE_BUF: usize = N_TTY_BUF_SIZE;
struct PtyBinOpts {
fpty: SafeOwnedFd,
is_debug: bool,
ws_x: Option<libc::c_ushort>,
ws_y: Option<libc::c_ushort>,
}
main! { pty_bin_main =>
let _ = set_name(SydId::get_cname(c"syd-pty"));
safe_drop_caps()?;
set_no_new_privs()?;
confine_landlock_scope(None::<SafeOwnedFd> , AccessFs::all(), AccessNet::all(), true )?;
let opts = parse_options()?;
ignore_signals(IgnoreSignalOpts::empty())?;
#[expect(clippy::cast_sign_loss)]
{
let fd = opts.fpty.as_raw_fd() as libc::c_uint;
let _ = closeexcept(&[0, 1, 2, fd]);
}
let PtyBinOpts {
fpty,
is_debug: debug,
ws_x,
ws_y,
} = opts;
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 (pipe_pty_rd, pipe_pty_wr) = {
let (rd, wr) = pipe2(OFlag::O_DIRECT | 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_DIRECT | 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(libc::STDIN_FILENO, OFlag::O_CLOEXEC)?;
let fstd_wr = duprand(libc::STDOUT_FILENO, OFlag::O_CLOEXEC)?;
set_exclusive(&fpty, true)?;
set_nonblock(&fpty, true)?;
set_nonblock(&fstd_rd, true)?;
set_nonblock(&fstd_wr, true)?;
unsafe { signal(Signal::SIGWINCH, SigHandler::SigDfl)? };
let mut mask = SigSet::empty();
mask.add(Signal::SIGWINCH);
sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask), None)?;
let fsig = {
let fd = SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC)?;
duprand(fd.as_raw_fd(), OFlag::O_CLOEXEC).map(|fd| {
unsafe { SignalFd::from_owned_fd(fd.into()) }
})?
};
refresh_pty(&fstd_rd, &fpty)?;
refresh_win(&fstd_rd, &fpty, ws_x, ws_y);
let print = env::var_os("SYD_PTY_RULES").is_some();
confine(fsig.as_raw_fd(), fstd_rd.as_raw_fd(), fpty.as_raw_fd(), debug, print)?;
let _ = close(libc::STDIN_FILENO);
let _ = close(libc::STDOUT_FILENO);
let _ = close(libc::STDERR_FILENO);
let result = pty_bin_run_forwarder(
&epoll,
&fpty,
&fsig,
&fstd_rd,
&fstd_wr,
&pipe_pty_rd,
&pipe_pty_wr,
&pipe_std_rd,
&pipe_std_wr,
);
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
Ok(match result {
Ok(_) => ExitCode::SUCCESS,
Err(err) => ExitCode::from(err.errno().unwrap_or(Errno::ENOSYS) as i32 as u8),
})
}
#[expect(clippy::too_many_arguments)]
fn pty_bin_run_forwarder<F1: AsFd, F2: AsFd, F3: AsFd, F4: AsFd, F5: AsFd, F6: AsFd, F7: AsFd>(
epoll: &Epoll,
pty_fd: &F1,
sig_fd: &SignalFd,
std_rd: &F2,
std_wr: &F3,
pipe_pty_rd: &F4,
pipe_pty_wr: &F5,
pipe_std_rd: &F6,
pipe_std_wr: &F7,
) -> SydResult<()> {
#[expect(clippy::cast_sign_loss)]
let event = libc::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 = libc::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 = libc::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))?;
#[expect(clippy::cast_sign_loss)]
let event = libc::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 is_inp && fd == sig_fd.as_raw_fd() {
loop {
let sig_info = match sig_fd.read_signal() {
Ok(Some(sig_info)) => {
sig_info
}
Ok(None) => {
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, None, None);
}
}
}
if is_inp {
if fd == std_rd.as_fd().as_raw_fd() {
splice_move(std_rd, pty_fd, pipe_pty_rd, pipe_pty_wr)?;
} 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)?;
} else if fd == pty_fd.as_fd().as_raw_fd() {
splice_pipe(pipe_pty_rd, pty_fd)?;
}
}
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() {
splice_pipe(pipe_std_rd, std_wr)?;
return Ok(());
}
}
}
}
}
fn confine(
sig_fd: RawFd,
std_fd: RawFd,
pty_fd: RawFd,
dry_run: bool,
print_rules: bool,
) -> SydResult<()> {
let mut ctx = new_filter(ScmpAction::KillProcess)?;
let allow_call = [
"exit",
"exit_group",
"sigaltstack",
"brk",
"mremap",
"munmap",
"close",
"splice",
"epoll_ctl",
"epoll_wait",
"epoll_pwait",
"epoll_pwait2",
];
for name in allow_call.iter().chain(ALLOC_SYSCALLS).chain(VDSO_SYSCALLS) {
if let Ok(syscall) = Sydcall::from_name(name) {
ctx.add_rule(ScmpAction::Allow, syscall)?;
}
}
confine_scmp_madvise(&mut ctx)?;
#[expect(clippy::disallowed_methods)]
let syscall = Sydcall::from_name("read").unwrap();
#[expect(clippy::cast_sign_loss)]
ctx.add_rule_conditional(
ScmpAction::Allow,
syscall,
&[scmp_cmp!($arg0 == sig_fd as u64)],
)?;
#[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 u64),
scmp_cmp!($arg1 & 0xFFFFFFFF == libc::TIOCGWINSZ as u64),
],
)?;
ctx.add_rule_conditional(
ScmpAction::Allow,
syscall,
&[
scmp_cmp!($arg0 == pty_fd as u64),
scmp_cmp!($arg1 & 0xFFFFFFFF == libc::TIOCSWINSZ as u64),
],
)?;
}
confine_scmp_fcntl(&mut ctx, PTY_FCNTL_OPS)?;
confine_scmp_prctl(&mut ctx, PTY_PRCTL_OPS)?;
confine_scmp_wx_syd(&mut ctx)?;
chdir("/proc/self/fdinfo")?;
if !dry_run {
std::panic::set_hook(Box::new(|_| {}));
let namespaces = CloneFlags::CLONE_NEWUSER
| CloneFlags::CLONE_NEWCGROUP
| CloneFlags::CLONE_NEWIPC
| CloneFlags::CLONE_NEWNET
| CloneFlags::CLONE_NEWNS
| CloneFlags::CLONE_NEWPID
| CloneFlags::CLONE_NEWUTS
| CLONE_NEWTIME;
if unshare(namespaces).is_ok() {
chroot(".")?; chdir("/")?; }
let abi = crate::landlock::ABI::new_current();
let policy = LandlockPolicy {
scoped_abs: true,
scoped_sig: true,
..Default::default()
};
let _ = policy.restrict_self(abi);
let _ = confine_mdwe(false);
set_dumpable(false)?;
confine_rlimit_zero(&[
Resource::RLIMIT_CORE,
Resource::RLIMIT_FSIZE,
Resource::RLIMIT_NOFILE,
Resource::RLIMIT_NPROC,
Resource::RLIMIT_LOCKS,
Resource::RLIMIT_MEMLOCK,
Resource::RLIMIT_MSGQUEUE,
])?;
}
if print_rules {
eprintln!("# syd-pty rules");
let _ = ctx.export_pfc(std::io::stderr());
}
if !dry_run {
ctx.load()?;
}
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> {
splice(
src,
None,
dst,
None,
PIPE_BUF,
SpliceFFlags::SPLICE_F_NONBLOCK | SpliceFFlags::SPLICE_F_MORE,
)
}
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<libc::c_ushort>,
ws_y: Option<libc::c_ushort>,
) {
if let Some(ws_row) = ws_x {
if let Some(ws_col) = ws_y {
let ws = libc::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);
tio.output_flags.insert(OutputFlags::OPOST);
tcsetattr(&src, SetArg::TCSANOW, &tio)?;
Ok(())
}
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('d') => opt_debug = true,
Short('i') => opt_fpty = Some(parser.value()?.parse::<String>()?),
Short('x') => {
opt_ws_x = Some(
parser
.value()?
.parse::<String>()?
.parse::<libc::c_ushort>()?,
)
}
Short('y') => {
opt_ws_y = Some(
parser
.value()?
.parse::<String>()?
.parse::<libc::c_ushort>()?,
)
}
_ => return Err(arg.unexpected().into()),
}
}
let fpty = if let Some(fpty) = opt_fpty {
let fpty = fpty.parse::<RawFd>()?;
if fpty < 0 {
return Err(Errno::EBADF.into());
}
let fpty = unsafe { SafeOwnedFd::from_raw_fd(fpty) };
fcntl(&fpty, FcntlArg::F_GETFD)?;
fpty
} else {
eprintln!("syd-pty: Error: -i is required.");
help();
exit(1);
};
Ok(PtyBinOpts {
fpty,
is_debug: opt_debug,
ws_x: opt_ws_x,
ws_y: opt_ws_y,
})
}
fn help() {
println!("Usage: syd-pty [-dh] -i <pty-fd> [-x x-size] [-y y-size]");
println!("Syd's PTY to STDIO bidirectional forwarder");
println!("Forwards data between the given pty(7) main file descriptor and stdio(3).");
println!("PID file descriptor is used to track the exit of Syd process.");
println!(" -h Print this help message and exit.");
println!(" -d Run in debug mode without confinement.");
println!(" -i <pty-fd> PTY main file descriptor.");
println!(" -x <x-size> Specify window row size (default: inherit).");
println!(" -y <y-size> Specify window column size (default: inherit).");
}