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, thiserror::Error)]
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    #[error("no console found (tried {}); over SSH there is no VT to mute", CANDIDATES.join(", "))]
82    NoConsole,
83    /// A console ioctl failed.
84    #[error("console ioctl failed: {0}")]
85    Ioctl(#[source] std::io::Error),
86    /// The console device could not be opened.
87    #[error("could not open {path}: {source}")]
88    Open {
89        /// The path that failed.
90        path: String,
91        /// Why.
92        #[source]
93        source: std::io::Error,
94    },
95}
96
97/// A handle to the virtual terminal, which restores whatever it changed on drop.
98///
99/// Nothing is changed by opening one. Call [`mute_keyboard`](Self::mute_keyboard)
100/// and [`graphics_mode`](Self::graphics_mode) for that, and each remembers the
101/// mode it replaced so [`restore`](Self::restore) puts back exactly what was
102/// there rather than a guess at the default.
103///
104/// ```no_run
105/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
106/// use denise_evdev::Console;
107///
108/// // A kiosk owns the screen and the keyboard for as long as this lives.
109/// let mut console = Console::open()?;
110/// console.mute_keyboard()?;
111/// console.graphics_mode()?;
112/// # Ok(())
113/// # }
114/// ```
115///
116/// Developing over SSH, [`Console::open`] returns [`ConsoleError::NoConsole`] and
117/// the right response is to carry on without one — see
118/// [`open_if_present`](Self::open_if_present).
119#[derive(Debug)]
120pub struct Console {
121    file: File,
122    /// The keyboard mode to put back, if we changed it.
123    keyboard: Option<u32>,
124    /// The text/graphics mode to put back, if we changed it.
125    screen: Option<u32>,
126}
127
128impl Console {
129    /// Opens the first of `/dev/tty`, `/dev/tty0`, `/dev/console` that is a real
130    /// virtual terminal.
131    ///
132    /// Returns [`ConsoleError::NoConsole`] when there is none — over SSH, under a
133    /// terminal emulator, or in a container.
134    pub fn open() -> Result<Self, ConsoleError> {
135        for path in CANDIDATES {
136            // A candidate that is missing, forbidden or not a console is not an
137            // error yet; the next one may work. Only running out of them is.
138            if let Ok(console) = Self::open_path(Path::new(path)) {
139                return Ok(console);
140            }
141        }
142        Err(ConsoleError::NoConsole)
143    }
144
145    /// [`open`](Self::open), but a missing console is `None` rather than an error.
146    ///
147    /// For the common shape: mute if there is something to mute, and run normally
148    /// on a development machine where there is not.
149    pub fn open_if_present() -> Option<Self> {
150        Self::open().ok()
151    }
152
153    /// Opens a specific console device.
154    ///
155    /// Fails with [`ConsoleError::NoConsole`] if the path opens but is not a
156    /// virtual terminal.
157    pub fn open_path(path: &Path) -> Result<Self, ConsoleError> {
158        let file = OpenOptions::new()
159            .read(true)
160            .write(true)
161            .open(path)
162            .map_err(|source| ConsoleError::Open {
163                path: path.display().to_string(),
164                source,
165            })?;
166
167        // SAFETY: KDGKBTYPE is a console opcode that writes one byte through the
168        // argument pointer. `Getter<_, u8>` supplies storage of exactly that size
169        // and reads it back only on success. On a non-console fd the ioctl fails,
170        // which is precisely the question being asked.
171        let kind = unsafe { ioctl::ioctl(file.as_fd(), Getter::<KDGKBTYPE, u8>::new()) };
172        if kind.is_err() {
173            return Err(ConsoleError::NoConsole);
174        }
175
176        Ok(Self {
177            file,
178            keyboard: None,
179            screen: None,
180        })
181    }
182
183    /// Stops console keystrokes reaching the shell behind the UI.
184    ///
185    /// evdev still delivers everything, so Denise's own input is unaffected. This
186    /// also disables `Ctrl+Alt+F<n>` VT switching, because the kernel discards
187    /// those keys along with the rest — read the module docs before using it on a
188    /// machine you cannot reach over the network.
189    ///
190    /// Calling it twice is harmless: the mode remembered is the one from before
191    /// the first call.
192    pub fn mute_keyboard(&mut self) -> Result<(), ConsoleError> {
193        if self.keyboard.is_none() {
194            // Read before writing, and give up if the read fails. Muting a
195            // console we cannot un-mute is the one outcome worth refusing.
196            self.keyboard = Some(self.keyboard_mode()?);
197        }
198        self.set_keyboard_mode(K_OFF)
199    }
200
201    /// Puts the console into graphics mode, so it stops drawing text over the
202    /// display and stops blanking it.
203    ///
204    /// DRM master already keeps fbcon off the scanout buffer in normal operation.
205    /// This covers what master does not: console blanking on an idle panel, and
206    /// the kernel repainting text after a VT switch or an oops.
207    pub fn graphics_mode(&mut self) -> Result<(), ConsoleError> {
208        if self.screen.is_none() {
209            self.screen = Some(self.screen_mode()?);
210        }
211        self.set_screen_mode(KD_GRAPHICS)
212    }
213
214    /// Puts back every mode this handle changed, and forgets them.
215    ///
216    /// Runs automatically on drop. Call it directly to hand the console back
217    /// early — before spawning a shell, say — or to see the error, which [`Drop`]
218    /// has nowhere to report.
219    pub fn restore(&mut self) -> Result<(), ConsoleError> {
220        let mut result = Ok(());
221        // Both are attempted even if the first fails: a console left in graphics
222        // mode is bad, and one left with no keyboard is worse, so neither should
223        // be skipped because of the other.
224        if let Some(mode) = self.keyboard.take() {
225            result = result.and(self.set_keyboard_mode(mode));
226        }
227        if let Some(mode) = self.screen.take() {
228            result = result.and(self.set_screen_mode(mode));
229        }
230        result
231    }
232
233    /// The current keyboard translation mode: one of [`K_RAW`], [`K_XLATE`],
234    /// [`K_MEDIUMRAW`], [`K_UNICODE`] or [`K_OFF`].
235    pub fn keyboard_mode(&self) -> Result<u32, ConsoleError> {
236        // SAFETY: KDGKBMODE writes one `int` through the argument pointer on a
237        // console fd, which `open_path` established this is. `Getter<_, u32>`
238        // provides storage of exactly that size.
239        let mode = unsafe { ioctl::ioctl(self.file.as_fd(), Getter::<KDGKBMODE, u32>::new()) };
240        mode.map_err(|e| ConsoleError::Ioctl(e.into()))
241    }
242
243    /// The current screen mode: [`KD_TEXT`] or [`KD_GRAPHICS`].
244    pub fn screen_mode(&self) -> Result<u32, ConsoleError> {
245        // SAFETY: as `keyboard_mode`, for KDGETMODE.
246        let mode = unsafe { ioctl::ioctl(self.file.as_fd(), Getter::<KDGETMODE, u32>::new()) };
247        mode.map_err(|e| ConsoleError::Ioctl(e.into()))
248    }
249
250    fn set_keyboard_mode(&self, mode: u32) -> Result<(), ConsoleError> {
251        // SAFETY: KDSKBMODE takes its mode as the integer argument rather than
252        // through a pointer. `mode` is either K_OFF or a value KDGKBMODE just
253        // returned, so it is in range by construction.
254        let result = unsafe {
255            ioctl::ioctl(
256                self.file.as_fd(),
257                IntegerSetter::<KDSKBMODE>::new_usize(mode as usize),
258            )
259        };
260        result.map_err(|e| ConsoleError::Ioctl(e.into()))
261    }
262
263    fn set_screen_mode(&self, mode: u32) -> Result<(), ConsoleError> {
264        // SAFETY: as `set_keyboard_mode`, for KDSETMODE and KD_GRAPHICS.
265        let result = unsafe {
266            ioctl::ioctl(
267                self.file.as_fd(),
268                IntegerSetter::<KDSETMODE>::new_usize(mode as usize),
269            )
270        };
271        result.map_err(|e| ConsoleError::Ioctl(e.into()))
272    }
273}
274
275impl AsFd for Console {
276    fn as_fd(&self) -> BorrowedFd<'_> {
277        self.file.as_fd()
278    }
279}
280
281impl Drop for Console {
282    fn drop(&mut self) {
283        // Deliberately ignored: drop has nowhere to report to, and leaving the
284        // console muted because the restore failed is strictly worse than trying
285        // and failing quietly. A caller that wants the error calls `restore`.
286        let _ = self.restore();
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    /// The numbers from `linux/kd.h`, spelled out.
295    ///
296    /// rustix composes opcodes from a group and a number, and a console opcode is
297    /// a bare `_IO` with no size or direction bits — so the composition should be
298    /// the identity. If it ever is not, or a digit here is wrong, these ioctls
299    /// would still be *valid*; they would just be a different driver command
300    /// against the same fd. Assert the values, not the arithmetic.
301    #[test]
302    fn opcodes_match_the_kernel_headers() {
303        assert_eq!(KDGKBTYPE, 0x4B33);
304        assert_eq!(KDSETMODE, 0x4B3A);
305        assert_eq!(KDGETMODE, 0x4B3B);
306        assert_eq!(KDGKBMODE, 0x4B44);
307        assert_eq!(KDSKBMODE, 0x4B45);
308    }
309
310    #[test]
311    fn mode_constants_match_the_kernel_headers() {
312        assert_eq!(
313            [K_RAW, K_XLATE, K_MEDIUMRAW, K_UNICODE, K_OFF],
314            [0, 1, 2, 3, 4]
315        );
316        assert_eq!([KD_TEXT, KD_GRAPHICS], [0, 1]);
317    }
318
319    /// `/dev/null` opens read-write on every Linux machine and is not a console.
320    ///
321    /// The gate this checks is the one standing between a developer over SSH and
322    /// a muted pty: without it, `open` would hand back the first thing that
323    /// opened and the ioctls would fail later, somewhere less obvious.
324    #[test]
325    fn a_character_device_that_is_not_a_console_is_refused() {
326        let result = Console::open_path(Path::new("/dev/null"));
327        assert!(
328            matches!(result, Err(ConsoleError::NoConsole)),
329            "expected NoConsole, got {result:?}"
330        );
331    }
332}