Skip to main content

denise_evdev/
console.rs

1//! Muting the kernel's virtual terminal.
2//!
3//! A Denise application that owns the display still shares the keyboard with
4//! whatever is behind it. On a console-booted kiosk that is a login shell, so
5//! every character typed into a Denise text field is also typed at the shell —
6//! and `reboot<Enter>` in a form field does what it says. Holding DRM master
7//! stops the console *drawing*; it does nothing about the keyboard.
8//!
9//! [`Console::mute_keyboard`] sets `KDSKBMODE` to `K_OFF`, which makes the kernel
10//! discard console keystrokes entirely. Reading `/dev/input/event*` is unaffected,
11//! because evdev sits below the console layer — which is the whole trick.
12//!
13//! # Getting your console back
14//!
15//! `K_OFF` is process-independent kernel state on a shared device. It is restored
16//! on [`Drop`], including while a panic unwinds, but nothing runs on `SIGKILL` or
17//! a hard reset, and a muted console has no working `Ctrl+Alt+F2` to escape
18//! through — `K_OFF` swallows that too.
19//!
20//! So write down the escape hatch before you need it. From SSH, or from another
21//! VT if you can still reach one:
22//!
23//! ```text
24//! kbd_mode -u -C /dev/tty1     # back to Unicode
25//! ```
26//!
27//! Do not mute during development on a machine you cannot reach over the network.
28
29use std::fs::{File, OpenOptions};
30use std::os::fd::{AsFd, BorrowedFd};
31use std::path::Path;
32
33use rustix::ioctl::{self, Getter, IntegerSetter, Opcode, opcode};
34
35/// `KDGKBTYPE` — reports the keyboard type, and fails on anything that is not a
36/// console. The standard test for "is this fd really a VT", because every other
37/// console ioctl either fails confusingly or, worse, does not.
38const KDGKBTYPE: Opcode = opcode::none(0x4B, 0x33);
39/// `KDGKBMODE` — read the keyboard translation mode.
40const KDGKBMODE: Opcode = opcode::none(0x4B, 0x44);
41/// `KDSKBMODE` — set it. Takes the mode as an integer argument, not a pointer.
42const KDSKBMODE: Opcode = opcode::none(0x4B, 0x45);
43/// `KDGETMODE` — read the console's text/graphics mode.
44const KDGETMODE: Opcode = opcode::none(0x4B, 0x3B);
45/// `KDSETMODE` — set it. Integer argument, as with `KDSKBMODE`.
46const KDSETMODE: Opcode = opcode::none(0x4B, 0x3A);
47
48/// `K_RAW`: the console delivers raw scancodes.
49pub const K_RAW: u32 = 0x00;
50/// `K_XLATE`: scancodes translated to bytes through the loaded keymap.
51pub const K_XLATE: u32 = 0x01;
52/// `K_MEDIUMRAW`: keycodes rather than scancodes, still untranslated.
53pub const K_MEDIUMRAW: u32 = 0x02;
54/// `K_UNICODE`: translated and UTF-8 encoded. Where a modern console sits.
55pub const K_UNICODE: u32 = 0x03;
56/// `K_OFF`: the kernel reads the keyboard and throws the result away.
57pub const K_OFF: u32 = 0x04;
58
59/// `KD_TEXT`: the console draws text. The default.
60pub const KD_TEXT: u32 = 0x00;
61/// `KD_GRAPHICS`: the console stops drawing text and stops blanking the screen.
62pub const KD_GRAPHICS: u32 = 0x01;
63
64/// Where to look for a console, in the order a kiosk wants them tried.
65///
66/// `/dev/tty` is the process's own controlling terminal, which is the VT itself
67/// when the application was started by a getty autologin — the normal kiosk boot
68/// — and needs no privilege. `/dev/tty0` is the *active* VT whoever owns it, and
69/// is `root`-only on most distributions.
70///
71/// Over SSH `/dev/tty` is a pty, and a pty is not a console. That is what
72/// `KDGKBTYPE` is for.
73const CANDIDATES: [&str; 3] = ["/dev/tty", "/dev/tty0", "/dev/console"];
74
75/// Something went wrong talking to the console.
76#[derive(Debug)]
77pub enum ConsoleError {
78    /// None of the candidate paths was openable and a real console.
79    ///
80    /// Over SSH this is the expected outcome: there is no VT to mute.
81    NoConsole,
82    /// A console ioctl failed.
83    Ioctl(std::io::Error),
84    /// The console device could not be opened.
85    Open {
86        /// The path that failed.
87        path: String,
88        /// Why.
89        source: std::io::Error,
90    },
91}
92
93impl std::fmt::Display for ConsoleError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::NoConsole => write!(
97                f,
98                "no console found (tried {}); over SSH there is no VT to mute",
99                CANDIDATES.join(", ")
100            ),
101            Self::Ioctl(err) => write!(f, "console ioctl failed: {err}"),
102            Self::Open { path, source } => write!(f, "could not open {path}: {source}"),
103        }
104    }
105}
106
107impl std::error::Error for ConsoleError {
108    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
109        match self {
110            Self::Ioctl(source) | Self::Open { source, .. } => Some(source),
111            Self::NoConsole => None,
112        }
113    }
114}
115
116/// A handle to the virtual terminal, which restores whatever it changed on drop.
117///
118/// Nothing is changed by opening one. Call [`mute_keyboard`](Self::mute_keyboard)
119/// and [`graphics_mode`](Self::graphics_mode) for that, and each remembers the
120/// mode it replaced so [`restore`](Self::restore) puts back exactly what was
121/// there rather than a guess at the default.
122///
123/// ```no_run
124/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
125/// use denise_evdev::Console;
126///
127/// // A kiosk owns the screen and the keyboard for as long as this lives.
128/// let mut console = Console::open()?;
129/// console.mute_keyboard()?;
130/// console.graphics_mode()?;
131/// # Ok(())
132/// # }
133/// ```
134///
135/// Developing over SSH, [`Console::open`] returns [`ConsoleError::NoConsole`] and
136/// the right response is to carry on without one — see
137/// [`open_if_present`](Self::open_if_present).
138#[derive(Debug)]
139pub struct Console {
140    file: File,
141    /// The keyboard mode to put back, if we changed it.
142    keyboard: Option<u32>,
143    /// The text/graphics mode to put back, if we changed it.
144    screen: Option<u32>,
145}
146
147impl Console {
148    /// Opens the first of `/dev/tty`, `/dev/tty0`, `/dev/console` that is a real
149    /// virtual terminal.
150    ///
151    /// Returns [`ConsoleError::NoConsole`] when there is none — over SSH, under a
152    /// terminal emulator, or in a container.
153    pub fn open() -> Result<Self, ConsoleError> {
154        for path in CANDIDATES {
155            // A candidate that is missing, forbidden or not a console is not an
156            // error yet; the next one may work. Only running out of them is.
157            if let Ok(console) = Self::open_path(Path::new(path)) {
158                return Ok(console);
159            }
160        }
161        Err(ConsoleError::NoConsole)
162    }
163
164    /// [`open`](Self::open), but a missing console is `None` rather than an error.
165    ///
166    /// For the common shape: mute if there is something to mute, and run normally
167    /// on a development machine where there is not.
168    pub fn open_if_present() -> Option<Self> {
169        Self::open().ok()
170    }
171
172    /// Opens a specific console device.
173    ///
174    /// Fails with [`ConsoleError::NoConsole`] if the path opens but is not a
175    /// virtual terminal.
176    pub fn open_path(path: &Path) -> Result<Self, ConsoleError> {
177        let file = OpenOptions::new()
178            .read(true)
179            .write(true)
180            .open(path)
181            .map_err(|source| ConsoleError::Open {
182                path: path.display().to_string(),
183                source,
184            })?;
185
186        // SAFETY: KDGKBTYPE is a console opcode that writes one byte through the
187        // argument pointer. `Getter<_, u8>` supplies storage of exactly that size
188        // and reads it back only on success. On a non-console fd the ioctl fails,
189        // which is precisely the question being asked.
190        let kind = unsafe { ioctl::ioctl(file.as_fd(), Getter::<KDGKBTYPE, u8>::new()) };
191        if kind.is_err() {
192            return Err(ConsoleError::NoConsole);
193        }
194
195        Ok(Self {
196            file,
197            keyboard: None,
198            screen: None,
199        })
200    }
201
202    /// Stops console keystrokes reaching the shell behind the UI.
203    ///
204    /// evdev still delivers everything, so Denise's own input is unaffected. This
205    /// also disables `Ctrl+Alt+F<n>` VT switching, because the kernel discards
206    /// those keys along with the rest — read the module docs before using it on a
207    /// machine you cannot reach over the network.
208    ///
209    /// Calling it twice is harmless: the mode remembered is the one from before
210    /// the first call.
211    pub fn mute_keyboard(&mut self) -> Result<(), ConsoleError> {
212        if self.keyboard.is_none() {
213            // Read before writing, and give up if the read fails. Muting a
214            // console we cannot un-mute is the one outcome worth refusing.
215            self.keyboard = Some(self.keyboard_mode()?);
216        }
217        self.set_keyboard_mode(K_OFF)
218    }
219
220    /// Puts the console into graphics mode, so it stops drawing text over the
221    /// display and stops blanking it.
222    ///
223    /// DRM master already keeps fbcon off the scanout buffer in normal operation.
224    /// This covers what master does not: console blanking on an idle panel, and
225    /// the kernel repainting text after a VT switch or an oops.
226    pub fn graphics_mode(&mut self) -> Result<(), ConsoleError> {
227        if self.screen.is_none() {
228            self.screen = Some(self.screen_mode()?);
229        }
230        self.set_screen_mode(KD_GRAPHICS)
231    }
232
233    /// Puts back every mode this handle changed, and forgets them.
234    ///
235    /// Runs automatically on drop. Call it directly to hand the console back
236    /// early — before spawning a shell, say — or to see the error, which [`Drop`]
237    /// has nowhere to report.
238    pub fn restore(&mut self) -> Result<(), ConsoleError> {
239        let mut result = Ok(());
240        // Both are attempted even if the first fails: a console left in graphics
241        // mode is bad, and one left with no keyboard is worse, so neither should
242        // be skipped because of the other.
243        if let Some(mode) = self.keyboard.take() {
244            result = result.and(self.set_keyboard_mode(mode));
245        }
246        if let Some(mode) = self.screen.take() {
247            result = result.and(self.set_screen_mode(mode));
248        }
249        result
250    }
251
252    /// The current keyboard translation mode: one of [`K_RAW`], [`K_XLATE`],
253    /// [`K_MEDIUMRAW`], [`K_UNICODE`] or [`K_OFF`].
254    pub fn keyboard_mode(&self) -> Result<u32, ConsoleError> {
255        // SAFETY: KDGKBMODE writes one `int` through the argument pointer on a
256        // console fd, which `open_path` established this is. `Getter<_, u32>`
257        // provides storage of exactly that size.
258        let mode = unsafe { ioctl::ioctl(self.file.as_fd(), Getter::<KDGKBMODE, u32>::new()) };
259        mode.map_err(|e| ConsoleError::Ioctl(e.into()))
260    }
261
262    /// The current screen mode: [`KD_TEXT`] or [`KD_GRAPHICS`].
263    pub fn screen_mode(&self) -> Result<u32, ConsoleError> {
264        // SAFETY: as `keyboard_mode`, for KDGETMODE.
265        let mode = unsafe { ioctl::ioctl(self.file.as_fd(), Getter::<KDGETMODE, u32>::new()) };
266        mode.map_err(|e| ConsoleError::Ioctl(e.into()))
267    }
268
269    fn set_keyboard_mode(&self, mode: u32) -> Result<(), ConsoleError> {
270        // SAFETY: KDSKBMODE takes its mode as the integer argument rather than
271        // through a pointer. `mode` is either K_OFF or a value KDGKBMODE just
272        // returned, so it is in range by construction.
273        let result = unsafe {
274            ioctl::ioctl(
275                self.file.as_fd(),
276                IntegerSetter::<KDSKBMODE>::new_usize(mode as usize),
277            )
278        };
279        result.map_err(|e| ConsoleError::Ioctl(e.into()))
280    }
281
282    fn set_screen_mode(&self, mode: u32) -> Result<(), ConsoleError> {
283        // SAFETY: as `set_keyboard_mode`, for KDSETMODE and KD_GRAPHICS.
284        let result = unsafe {
285            ioctl::ioctl(
286                self.file.as_fd(),
287                IntegerSetter::<KDSETMODE>::new_usize(mode as usize),
288            )
289        };
290        result.map_err(|e| ConsoleError::Ioctl(e.into()))
291    }
292}
293
294impl AsFd for Console {
295    fn as_fd(&self) -> BorrowedFd<'_> {
296        self.file.as_fd()
297    }
298}
299
300impl Drop for Console {
301    fn drop(&mut self) {
302        // Deliberately ignored: drop has nowhere to report to, and leaving the
303        // console muted because the restore failed is strictly worse than trying
304        // and failing quietly. A caller that wants the error calls `restore`.
305        let _ = self.restore();
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// The numbers from `linux/kd.h`, spelled out.
314    ///
315    /// rustix composes opcodes from a group and a number, and a console opcode is
316    /// a bare `_IO` with no size or direction bits — so the composition should be
317    /// the identity. If it ever is not, or a digit here is wrong, these ioctls
318    /// would still be *valid*; they would just be a different driver command
319    /// against the same fd. Assert the values, not the arithmetic.
320    #[test]
321    fn opcodes_match_the_kernel_headers() {
322        assert_eq!(KDGKBTYPE, 0x4B33);
323        assert_eq!(KDSETMODE, 0x4B3A);
324        assert_eq!(KDGETMODE, 0x4B3B);
325        assert_eq!(KDGKBMODE, 0x4B44);
326        assert_eq!(KDSKBMODE, 0x4B45);
327    }
328
329    #[test]
330    fn mode_constants_match_the_kernel_headers() {
331        assert_eq!(
332            [K_RAW, K_XLATE, K_MEDIUMRAW, K_UNICODE, K_OFF],
333            [0, 1, 2, 3, 4]
334        );
335        assert_eq!([KD_TEXT, KD_GRAPHICS], [0, 1]);
336    }
337
338    /// `/dev/null` opens read-write on every Linux machine and is not a console.
339    ///
340    /// The gate this checks is the one standing between a developer over SSH and
341    /// a muted pty: without it, `open` would hand back the first thing that
342    /// opened and the ioctls would fail later, somewhere less obvious.
343    #[test]
344    fn a_character_device_that_is_not_a_console_is_refused() {
345        let result = Console::open_path(Path::new("/dev/null"));
346        assert!(
347            matches!(result, Err(ConsoleError::NoConsole)),
348            "expected NoConsole, got {result:?}"
349        );
350    }
351}