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}