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}