Skip to main content

libcontainer/
tty.rs

1//! tty (teletype) for user-system interaction
2//!
3//! This module handles console/TTY setup for containers.
4//!
5//! Console setup is done AFTER pivot_root (following runc's approach).
6//! This follows runc's approach in prepareRootfs():
7//! 1. pivot_root is called first
8//! 2. Create PTY pair from /dev/pts/ptmx (container's devpts)
9//! 3. Mount PTY slave onto /dev/console
10//! 4. Send PTY master to console socket
11//! 5. Set controlling terminal and connect stdio
12//!
13//! See: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/rootfs_linux.go
14
15use std::env;
16use std::io::IoSlice;
17use std::os::fd::OwnedFd;
18use std::os::unix::fs::{OpenOptionsExt, symlink};
19use std::os::unix::io::AsRawFd;
20use std::os::unix::prelude::RawFd;
21use std::path::{Path, PathBuf};
22
23use nix::sys::socket::{self, UnixAddr};
24use nix::sys::stat::{SFlag, major, minor};
25use nix::sys::statfs::FsType;
26use nix::unistd::{close, dup2};
27
28use crate::syscall::Syscall;
29use crate::utils::{VerifyInodeError, verify_inode};
30
31#[derive(Debug)]
32pub enum StdIO {
33    Stdin = 0,
34    Stdout = 1,
35    Stderr = 2,
36}
37
38impl From<StdIO> for i32 {
39    fn from(value: StdIO) -> Self {
40        match value {
41            StdIO::Stdin => 0,
42            StdIO::Stdout => 1,
43            StdIO::Stderr => 2,
44        }
45    }
46}
47
48impl std::fmt::Display for StdIO {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            StdIO::Stdin => write!(f, "stdin"),
52            StdIO::Stdout => write!(f, "stdout"),
53            StdIO::Stderr => write!(f, "stderr"),
54        }
55    }
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum TTYError {
60    #[error("failed to connect/duplicate {stdio}")]
61    ConnectStdIO { source: nix::Error, stdio: StdIO },
62    #[error("failed to create console socket")]
63    CreateConsoleSocket {
64        source: nix::Error,
65        socket_name: String,
66    },
67    #[error("failed to symlink console socket into container_dir")]
68    Symlink {
69        source: std::io::Error,
70        linked: Box<PathBuf>,
71        console_socket_path: Box<PathBuf>,
72    },
73    #[error("invalid socket name: {socket_name:?}")]
74    InvalidSocketName {
75        socket_name: String,
76        source: nix::Error,
77    },
78    #[error("failed to create console socket fd")]
79    CreateConsoleSocketFd { source: nix::Error },
80    #[error("could not create pseudo terminal")]
81    CreatePseudoTerminal { source: nix::Error },
82    #[error("failed to send pty master")]
83    SendPtyMaster { source: nix::Error },
84    #[error("could not close console socket")]
85    CloseConsoleSocket { source: nix::Error },
86    #[error("failed to create /dev/console")]
87    CreateDevConsole { source: std::io::Error },
88    #[error("failed to mount pty on /dev/console")]
89    MountConsole {
90        source: crate::syscall::SyscallError,
91    },
92    #[error("invalid PTY device: {reason}")]
93    InvalidPty { reason: String },
94    #[error("stat operation failed")]
95    Stat(#[from] nix::Error),
96}
97
98type Result<T> = std::result::Result<T, TTYError>;
99
100// TODO: Handling when there isn't console-socket.
101pub fn setup_console_socket(
102    container_dir: &Path,
103    console_socket_path: &Path,
104    socket_name: &str,
105) -> Result<OwnedFd> {
106    struct CurrentDirGuard {
107        path: PathBuf,
108    }
109    impl Drop for CurrentDirGuard {
110        fn drop(&mut self) {
111            let _ = env::set_current_dir(&self.path);
112        }
113    }
114    // Move into the container directory to avoid sun family conflicts with long socket path names.
115    // ref: https://github.com/youki-dev/youki/issues/2910
116
117    let prev_dir = env::current_dir().unwrap();
118    let _ = env::set_current_dir(container_dir);
119    let _guard = CurrentDirGuard { path: prev_dir };
120
121    let linked = PathBuf::from(socket_name);
122
123    symlink(console_socket_path, &linked).map_err(|err| TTYError::Symlink {
124        source: err,
125        linked: linked.to_path_buf().into(),
126        console_socket_path: console_socket_path.to_path_buf().into(),
127    })?;
128    let csocketfd = socket::socket(
129        socket::AddressFamily::Unix,
130        socket::SockType::Stream,
131        socket::SockFlag::empty(),
132        None,
133    )
134    .map_err(|err| TTYError::CreateConsoleSocketFd { source: err })?;
135    socket::connect(
136        csocketfd.as_raw_fd(),
137        &socket::UnixAddr::new(linked.as_path()).map_err(|err| TTYError::InvalidSocketName {
138            source: err,
139            socket_name: socket_name.to_string(),
140        })?,
141    )
142    .map_err(|e| TTYError::CreateConsoleSocket {
143        source: e,
144        socket_name: socket_name.to_string(),
145    })?;
146
147    Ok(csocketfd)
148}
149
150/// Device numbers from Linux kernel headers.
151/// TTYAUX_MAJOR from <linux/major.h>
152const PTMX_MAJOR: u64 = 5;
153/// From mknod_ptmx in fs/devpts/inode.c
154const PTMX_MINOR: u64 = 2;
155/// From mknod_ptmx in fs/devpts/inode.c
156const PTMX_INO: u64 = 2;
157/// PTY slave major number
158const PTY_SLAVE_MAJOR: u64 = 136;
159/// DEVPTS_SUPER_MAGIC from <linux/magic.h>
160/// Note: Using libc::DEVPTS_SUPER_MAGIC is not available, so we define it here.
161/// The type must match nix::sys::statfs::FsType's inner type (fs_type_t),
162/// which is i64 on glibc and u64 on musl.
163#[cfg(target_env = "musl")]
164const DEVPTS_SUPER_MAGIC: u64 = 0x1cd1;
165#[cfg(not(target_env = "musl"))]
166const DEVPTS_SUPER_MAGIC: i64 = 0x1cd1;
167/// Path to the PTY master device
168const PTMX_PATH: &[u8] = b"/dev/ptmx";
169/// Path to the console device
170const CONSOLE_PATH: &str = "/dev/console";
171
172/// Verify that the ptmx handle points to a real /dev/pts/ptmx device.
173///
174/// This follows runc's checkPtmxHandle pattern (CVE-2025-52565 mitigation):
175/// - Must be on a real devpts mount
176/// - Must have the correct inode number (2)
177/// - Must be a character device with major:minor = 5:2
178///
179/// Ref: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/console_linux.go
180fn verify_ptmx_handle(ptmx: &OwnedFd) -> Result<()> {
181    verify_inode(ptmx, |stat, fs_stat| {
182        // 1. Check filesystem type is devpts
183        if fs_stat.filesystem_type() != FsType(DEVPTS_SUPER_MAGIC) {
184            return Err(VerifyInodeError::Verification(format!(
185                "ptmx handle is not on a real devpts mount: super magic is {:#x}",
186                fs_stat.filesystem_type().0
187            )));
188        }
189
190        // 2. Check inode number
191        if stat.st_ino != PTMX_INO {
192            return Err(VerifyInodeError::Verification(format!(
193                "ptmx handle has wrong inode number: {}",
194                stat.st_ino
195            )));
196        }
197
198        // 3. Check it's a character device with correct major:minor
199        let mode_type = SFlag::from_bits_truncate(stat.st_mode) & SFlag::S_IFMT;
200        let dev_major = major(stat.st_rdev);
201        let dev_minor = minor(stat.st_rdev);
202
203        if mode_type != SFlag::S_IFCHR || dev_major != PTMX_MAJOR || dev_minor != PTMX_MINOR {
204            return Err(VerifyInodeError::Verification(format!(
205                "ptmx handle is not a real char ptmx device: ftype {:#x} {}:{}",
206                mode_type.bits(),
207                dev_major,
208                dev_minor
209            )));
210        }
211
212        tracing::debug!(
213            ptmx_fd = ptmx.as_raw_fd(),
214            ino = stat.st_ino,
215            "verified ptmx handle"
216        );
217        Ok(())
218    })
219    .map_err(|e| TTYError::InvalidPty {
220        reason: e.to_string(),
221    })
222}
223
224/// Verify that the slave handle points to a real PTY slave device.
225///
226/// This validates (CVE-2025-52565 mitigation):
227/// - Must be on a real devpts mount
228/// - Must be a character device with PTY slave major number (136)
229///
230/// Ref: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/console_linux.go
231fn verify_pty_slave(slave: &OwnedFd) -> Result<()> {
232    verify_inode(slave, |stat, fs_stat| {
233        // 1. Check filesystem type is devpts
234        if fs_stat.filesystem_type() != FsType(DEVPTS_SUPER_MAGIC) {
235            return Err(VerifyInodeError::Verification(format!(
236                "slave handle is not on a real devpts mount: super magic is {:#x}",
237                fs_stat.filesystem_type().0
238            )));
239        }
240
241        // 2. Check it's a character device with PTY slave major number
242        let mode_type = SFlag::from_bits_truncate(stat.st_mode) & SFlag::S_IFMT;
243        let dev_major = major(stat.st_rdev);
244
245        if mode_type != SFlag::S_IFCHR || dev_major != PTY_SLAVE_MAJOR {
246            return Err(VerifyInodeError::Verification(format!(
247                "slave handle is not a real PTY slave device: ftype {:#x} major {}",
248                mode_type.bits(),
249                dev_major
250            )));
251        }
252
253        tracing::debug!(
254            slave_fd = slave.as_raw_fd(),
255            major = dev_major,
256            minor = minor(stat.st_rdev),
257            "verified PTY slave"
258        );
259        Ok(())
260    })
261    .map_err(|e| TTYError::InvalidPty {
262        reason: e.to_string(),
263    })
264}
265
266/// Setup console AFTER pivot_root.
267///
268/// This function should be called AFTER pivot_root. This follows runc's approach:
269/// setupConsole is called after pivotRoot in prepareRootfs.
270///
271/// The process:
272/// 1. Create PTY pair from /dev/pts/ptmx (we're already in the container)
273/// 2. Optionally mount PTY slave on /dev/console (bind mount) - only for init
274/// 3. Send PTY master to console socket
275/// 4. Set controlling terminal
276/// 5. Connect stdio to PTY slave
277///
278/// # Arguments
279/// * `console_fd` - The console socket file descriptor
280/// * `mount` - Whether to mount PTY slave on /dev/console (true for init, false for exec)
281///
282/// By creating PTY from container's devpts, the PTY belongs to a mount that
283/// exists within the container's namespace, which is required for CRIU checkpoint.
284///
285/// See: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/rootfs_linux.go
286pub fn setup_console(syscall: &dyn Syscall, console_fd: RawFd, mount: bool) -> Result<()> {
287    // Create PTY pair from /dev/pts/ptmx
288    // After pivot_root, /dev/pts points to the container's devpts
289    let openpty_result = nix::pty::openpty(None, None)
290        .map_err(|err| TTYError::CreatePseudoTerminal { source: err })?;
291
292    let master = &openpty_result.master;
293    let slave = &openpty_result.slave;
294
295    // Verify both master and slave are real PTY devices (CVE-2025-52565 mitigation)
296    verify_ptmx_handle(master)?;
297    verify_pty_slave(slave)?;
298
299    // Mount PTY slave on /dev/console (only for init container)
300    if mount {
301        mount_console(syscall, slave)?;
302    }
303
304    // Send PTY master to console socket
305    let pty_name: &[u8] = PTMX_PATH;
306    let iov = [IoSlice::new(pty_name)];
307    let fds = [master.as_raw_fd()];
308    let cmsg = socket::ControlMessage::ScmRights(&fds);
309    socket::sendmsg::<UnixAddr>(console_fd, &iov, &[cmsg], socket::MsgFlags::empty(), None)
310        .map_err(|err| TTYError::SendPtyMaster { source: err })?;
311
312    // Set controlling terminal
313    if unsafe { libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY) } < 0 {
314        tracing::warn!("could not TIOCSCTTY");
315    };
316
317    // Connect stdio to PTY slave
318    connect_stdio(&slave.as_raw_fd(), &slave.as_raw_fd(), &slave.as_raw_fd())?;
319
320    // Close console socket
321    close(console_fd).map_err(|err| TTYError::CloseConsoleSocket { source: err })?;
322
323    Ok(())
324}
325
326/// Mount PTY slave on /dev/console.
327///
328/// This bind-mounts the PTY slave device onto /dev/console so programs
329/// that operate on /dev/console use the container's PTY.
330///
331/// This is called AFTER pivot_root, so we mount onto /dev/console directly.
332/// Uses FD-based mounting to avoid path resolution vulnerabilities (CVE-2025-52565).
333///
334/// See: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/rootfs_linux.go
335fn mount_console(syscall: &dyn Syscall, slave: &OwnedFd) -> Result<()> {
336    use std::fs::OpenOptions;
337
338    let console_path = Path::new(CONSOLE_PATH);
339
340    tracing::debug!(
341        slave_fd = slave.as_raw_fd(),
342        "mounting PTY on {CONSOLE_PATH}"
343    );
344
345    // Create /dev/console mount target.
346    // O_NOFOLLOW: prevent symlink attacks (CVE-2025-52565)
347    // O_CLOEXEC: close on exec
348    // Ref: https://github.com/opencontainers/runc/blob/v1.4.0/libcontainer/rootfs_linux.go
349    OpenOptions::new()
350        .create(true)
351        .write(true)
352        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
353        .mode(0o666)
354        .open(console_path)
355        .map_err(|err| {
356            tracing::error!(?err, "failed to create {CONSOLE_PATH}");
357            TTYError::CreateDevConsole { source: err }
358        })?;
359
360    // Bind mount the PTY slave onto /dev/console using FD-based mounting.
361    // This avoids path resolution vulnerabilities (CVE-2025-52565).
362    syscall.mount_from_fd(slave, console_path).map_err(|err| {
363        tracing::error!(
364            ?err,
365            slave_fd = slave.as_raw_fd(),
366            "failed to bind mount pty on {CONSOLE_PATH}"
367        );
368        TTYError::MountConsole { source: err }
369    })?;
370
371    tracing::debug!(
372        slave_fd = slave.as_raw_fd(),
373        "mounted PTY on {CONSOLE_PATH}"
374    );
375    Ok(())
376}
377
378fn connect_stdio(stdin: &RawFd, stdout: &RawFd, stderr: &RawFd) -> Result<()> {
379    dup2(stdin.as_raw_fd(), StdIO::Stdin.into()).map_err(|err| TTYError::ConnectStdIO {
380        source: err,
381        stdio: StdIO::Stdin,
382    })?;
383    dup2(stdout.as_raw_fd(), StdIO::Stdout.into()).map_err(|err| TTYError::ConnectStdIO {
384        source: err,
385        stdio: StdIO::Stdout,
386    })?;
387    // FIXME: Rarely does it fail.
388    // error message: `Error: Resource temporarily unavailable (os error 11)`
389    dup2(stderr.as_raw_fd(), StdIO::Stderr.into()).map_err(|err| TTYError::ConnectStdIO {
390        source: err,
391        stdio: StdIO::Stderr,
392    })?;
393
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use std::fs::File;
400    use std::os::fd::IntoRawFd;
401    use std::os::unix::net::UnixListener;
402
403    use anyhow::{Ok, Result};
404    use serial_test::serial;
405
406    use super::*;
407
408    const CONSOLE_SOCKET: &str = "console-socket";
409
410    #[test]
411    #[serial]
412    fn test_setup_console_socket() -> Result<()> {
413        let testdir = tempfile::tempdir()?;
414        let socket_path = Path::join(testdir.path(), "test-socket");
415        let lis = UnixListener::bind(&socket_path);
416        assert!(lis.is_ok());
417        let fd = setup_console_socket(testdir.path(), &socket_path, CONSOLE_SOCKET)?;
418        assert_ne!(fd.as_raw_fd(), -1);
419        Ok(())
420    }
421
422    #[test]
423    #[serial]
424    fn test_setup_console_socket_empty() -> Result<()> {
425        let testdir = tempfile::tempdir()?;
426        let socket_path = Path::join(testdir.path(), "test-socket");
427        let fd = setup_console_socket(testdir.path(), &socket_path, CONSOLE_SOCKET);
428        assert!(fd.is_err());
429        Ok(())
430    }
431
432    #[test]
433    #[serial]
434    fn test_setup_console_socket_invalid() -> Result<()> {
435        let testdir = tempfile::tempdir()?;
436        let socket_path = Path::join(testdir.path(), "test-socket");
437        let _socket = File::create(Path::join(testdir.path(), "console-socket"));
438        assert!(_socket.is_ok());
439        let fd = setup_console_socket(testdir.path(), &socket_path, CONSOLE_SOCKET);
440        assert!(fd.is_err());
441
442        Ok(())
443    }
444
445    #[test]
446    #[serial]
447    fn test_setup_console() -> Result<()> {
448        use crate::syscall::syscall::create_syscall;
449
450        let testdir = tempfile::tempdir()?;
451        let socket_path = Path::join(testdir.path(), "test-socket");
452
453        // duplicate the existing std* fds
454        // we need to restore them later, and we cannot simply store them
455        // as they themselves get modified in setup_console
456        let old_stdin: RawFd = nix::unistd::dup(StdIO::Stdin.into())?;
457        let old_stdout: RawFd = nix::unistd::dup(StdIO::Stdout.into())?;
458        let old_stderr: RawFd = nix::unistd::dup(StdIO::Stderr.into())?;
459
460        let lis = UnixListener::bind(&socket_path);
461        assert!(lis.is_ok());
462        let fd = setup_console_socket(testdir.path(), &socket_path, CONSOLE_SOCKET)?;
463        // This test verifies PTY setup behavior that occurs after pivot_root.
464        // mount=false because mounting /dev/console requires an actual container
465        // environment with proper namespace setup (pivot_root completed).
466        let syscall = create_syscall();
467        let status = setup_console(syscall.as_ref(), fd.into_raw_fd(), false);
468
469        // restore the original std* before doing final assert
470        dup2(old_stdin, StdIO::Stdin.into())?;
471        dup2(old_stdout, StdIO::Stdout.into())?;
472        dup2(old_stderr, StdIO::Stderr.into())?;
473
474        assert!(status.is_ok(), "setup_console failed: {:?}", status);
475
476        Ok(())
477    }
478
479    #[test]
480    fn test_verify_pty_slave_with_real_pty() -> Result<()> {
481        // Allocate a real PTY pair
482        let openpty_result = nix::pty::openpty(None, None)
483            .map_err(|e| TTYError::CreatePseudoTerminal { source: e })?;
484
485        // Verify slave handle should succeed
486        let result = verify_pty_slave(&openpty_result.slave);
487        assert!(result.is_ok(), "verify_pty_slave failed: {:?}", result);
488
489        Ok(())
490    }
491
492    #[test]
493    fn test_verify_ptmx_handle_with_real_pty() -> Result<()> {
494        // Allocate a real PTY pair
495        let openpty_result = nix::pty::openpty(None, None)
496            .map_err(|e| TTYError::CreatePseudoTerminal { source: e })?;
497
498        // Verify ptmx handle should succeed
499        let result = verify_ptmx_handle(&openpty_result.master);
500        assert!(result.is_ok(), "verify_ptmx_handle failed: {:?}", result);
501
502        Ok(())
503    }
504
505    #[test]
506    fn test_verify_ptmx_handle_with_regular_file() {
507        use std::fs::File;
508        use std::os::fd::AsFd;
509
510        use tempfile::tempfile;
511
512        // Create a regular file
513        let file: File = tempfile().expect("failed to create tempfile");
514        let fd = file.as_fd().try_clone_to_owned().unwrap();
515
516        // Verify should fail for regular file
517        let result = verify_ptmx_handle(&fd);
518        assert!(
519            result.is_err(),
520            "verify_ptmx_handle should fail for regular file"
521        );
522
523        if let Err(TTYError::InvalidPty { reason }) = result {
524            assert!(
525                reason.contains("devpts") || reason.contains("inode") || reason.contains("device"),
526                "unexpected error reason: {}",
527                reason
528            );
529        }
530    }
531
532    #[test]
533    fn test_verify_pty_slave_with_regular_file() {
534        use std::fs::File;
535        use std::os::fd::AsFd;
536
537        use tempfile::tempfile;
538
539        // Create a regular file
540        let file: File = tempfile().expect("failed to create tempfile");
541        let fd = file.as_fd().try_clone_to_owned().unwrap();
542
543        // Verify should fail for regular file
544        let result = verify_pty_slave(&fd);
545        assert!(
546            result.is_err(),
547            "verify_pty_slave should fail for regular file"
548        );
549
550        if let Err(TTYError::InvalidPty { reason }) = result {
551            assert!(
552                reason.contains("devpts") || reason.contains("device"),
553                "unexpected error reason: {}",
554                reason
555            );
556        }
557    }
558}