From a900ed1cf7a679207028f33faf21587458abd968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20WALLEZ?= Date: Tue, 4 Aug 2026 18:29:05 +0000 Subject: [PATCH 1/5] feat(login): sign in to Entra ID from a terminal, with no screen and no VNC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A headless host still has to sign in interactively: the broker's Primary Refresh Token expires, Entra answers interaction_required, and every token call fails until a human completes a sign-in. There was no way to do that over SSH. There is no text-only path to give, either. The broker refuses a device-code flow on Linux ('AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform') and renders the sign-in itself, in an embedded WebKitGTK view on an X display. So `login` does not replace the window — it brings it to the terminal: * xvfb.rs owns a private invisible display for the session; * ops::portal_start runs the Intune portal on it (the enroll flow, split so the caller can drive the window between its steps); * termview.rs draws the display with half-block cells, one repaint sending only the cells that changed; * xscreen.rs reads the pixels with GetImage and sends keys and clicks with XTEST. Four failures found while proving it against the real portal, each now covered: * the display was handed over as soon as its socket appeared, but a socket is bound before the server listens — the first connection was refused; * SIGKILL on teardown left the socket and lock behind, so the next run found a display number that nothing was using but nothing could use; * with no window manager the portal mapped its dialog at 615,375, putting the Sign in button past the bottom edge — place_and_focus moves it to the origin; * O_NONBLOCK on stdin also applies to stdout, so a frame larger than the tty buffer died with EAGAIN mid-session. Input is polled instead. A hangup is now a normal exit, because this is run over SSH: the portal is closed, the container returns to headless and the X server stops. --- Cargo.lock | 28 ++ Cargo.toml | 9 +- README.md | 29 ++ src/lib.rs | 10 + src/login.rs | 555 +++++++++++++++++++++++++++++++++++ src/main.rs | 23 +- src/ops.rs | 77 ++++- src/termview.rs | 767 ++++++++++++++++++++++++++++++++++++++++++++++++ src/xscreen.rs | 496 +++++++++++++++++++++++++++++++ src/xvfb.rs | 151 ++++++++++ 10 files changed, 2139 insertions(+), 6 deletions(-) create mode 100644 src/login.rs create mode 100644 src/termview.rs create mode 100644 src/xscreen.rs create mode 100644 src/xvfb.rs diff --git a/Cargo.lock b/Cargo.lock index 9854b0f..bbaebaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1406,6 +1406,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1976,6 +1986,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "x11rb", "zbus 4.4.0", "zstd", ] @@ -5710,6 +5721,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index c6f65cf..8331f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,9 @@ gui = [ [dependencies] # --- Core --- -nix = { version = "0.29", features = ["process", "signal", "user", "fs", "mount", "sched", "hostname", "term"] } +# `poll` is for the terminal sign-in: it waits for a keystroke without putting the +# tty in non-blocking mode, which would also apply to the frames written back to it. +nix = { version = "0.29", features = ["process", "signal", "user", "fs", "mount", "sched", "hostname", "term", "poll"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -69,6 +71,11 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io- # bundled roots so it works inside the minimal container too (no system CA / curl). reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +# --- Terminal sign-in (`login`) --- +# Reads the private display's pixels (GetImage) and types into it (XTEST). Pure +# Rust — no libX11, no C toolchain — so the statically linked CLI keeps working. +x11rb = { version = "0.13", features = ["xtest"] } + # --- CLI front-end --- clap = { version = "4", features = ["derive"] } rpassword = "7" diff --git a/README.md b/README.md index 176f1b5..ef0f414 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,32 @@ intune-container start # (optional) run headless + seamless Teams/M365 SSO Daily CLI use: `edge` · `status` · `doctor` · `stop`. Full walkthrough in the **[Quickstart](docs/quickstart.md)**. +### No screen at all? Sign in from the terminal + +On a headless box — an EC2 instance, a build machine — there is no display to put +the sign-in window on, and the Primary Refresh Token still expires: Entra then +answers `interaction_required` and every token call fails until a human signs in +again. `login` does that over SSH, with no VNC and no compositor: + +```sh +intune-container login # the sign-in window, drawn in your terminal +``` + +It starts a private invisible display, runs the portal on it, draws that display +with half-block characters and sends your keys and clicks back. Type your address +and password where you are; when the two-digit Authenticator number appears, press +**F2** to zoom in and read it. **F1** lists the keys, **F4** fits the whole window, +**Ctrl+arrows** pan, **Ctrl+Q** leaves. + +It needs `Xvfb` on the host (`sudo apt install xvfb`) and a terminal with 24-bit +colour. Everything it starts — the portal, the display forwarding, the X server — +it stops again, including when your SSH connection drops. + +Why not a plain text prompt? Because the identity broker allows none: it refuses a +device-code flow on Linux (`AcquireTokenWithDeviceCodeFlow is not implemented on +Linux platform`) and renders the sign-in itself, in an embedded WebKitGTK view on +an X display. The window has to exist — this brings it to the terminal. + The default container image is publicly hosted and ready to go (it already includes everything for headless SSO) — there's nothing to build, and no container engine is needed: the image is pulled with a built-in OCI client. @@ -125,6 +151,9 @@ shells out, and neither needs elevated privilege. - [x] **Compositor-agnostic** — auto-detects Wayland, abstract X11, and Xauthority; no hardcoded socket names. - [x] **One-command enroll** — provision, boot, and open the portal in one step. +- [x] **Terminal sign-in** (`login`) — for a host with no screen: the portal runs + on a private invisible display and is drawn in the terminal, keys and clicks + included. No VNC server, no compositor, no X forwarding. - [x] **Enrollment backup/restore** — survive container rebuilds without re-enrolling. - [x] **Microsoft Edge** in the container, with display/GPU passthrough during diff --git a/src/lib.rs b/src/lib.rs index 7c67725..a62ad0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,9 +20,19 @@ pub mod config; pub mod display; pub mod doctor; pub mod lock; +pub mod login; pub mod native_host; pub mod ops; +/// Drawing an X display in a terminal, and typing back into it (the pure half). +pub mod termview; + +/// Reading an X display's pixels and synthesising its input (the X11 half). +pub mod xscreen; + +/// The private, invisible X display a terminal sign-in runs on. +pub mod xvfb; + /// Pure-Rust OCI image pull + layer extraction (no docker/podman). pub mod oci; diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..f0f9c6f --- /dev/null +++ b/src/login.rs @@ -0,0 +1,555 @@ +//! `intune-container login` — sign in to Entra ID from a terminal, with no +//! screen, no compositor and no VNC. +//! +//! WHY THIS EXISTS. A headless server (an EC2 box, a build machine) still has to +//! sign in interactively: the identity broker's Primary Refresh Token expires, +//! Entra answers `interaction_required`, and every token call fails until a human +//! completes a sign-in. There is no way around the *window*: +//! +//! * the broker refuses a device-code flow on Linux — its own binary says +//! "AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform"; and +//! * it renders the sign-in itself, in an embedded WebKitGTK view on an X display +//! (`Msai::EmbeddedBrowserImpl`, `XOpenDisplay`), which no API can bypass. +//! +//! So this command does not replace the window — it brings it to the terminal. It +//! owns a private [`Xvfb`](crate::xvfb), launches the Intune portal on it, draws +//! that display with half-block characters ([`crate::termview`]) and sends the +//! keys and clicks back with XTEST ([`crate::xscreen`]). The reader types their +//! address and password in the terminal and reads the Authenticator number there. +//! +//! Everything it starts, it stops: the portal, the display forwarding and the X +//! server are all torn down on the way out, whether the reader quits or the +//! window closes. + +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use nix::sys::termios::{self, SetArg, Termios}; + +use crate::termview::{self, Action, Cell, Frame, Viewport}; +use crate::xscreen::XScreen; +use crate::xvfb::Xvfb; +use crate::{display, ops}; + +/// How the reader asked for the session to look. +#[derive(Debug, Clone, Copy)] +pub struct Options { + /// The size of the private display, in pixels. + pub width: u32, + pub height: u32, + /// Force a display number instead of taking the first free one. + pub display: Option, +} + +impl Default for Options { + fn default() -> Self { + // 1280×800 is the smallest size at which the Entra sign-in page keeps its + // desktop layout; narrower and it reflows to the phone one, which puts the + // number-matching code in a different place every step. + Self { + width: 1280, + height: 800, + display: None, + } + } +} + +/// Read a `WIDTHxHEIGHT` geometry. +/// +/// Bounded on both sides: under 640×480 the sign-in page has nowhere to put a +/// dialog, and a display far larger than the terminal is pixels nobody can read +/// (and a capture that costs more than it shows). +pub fn parse_geometry(text: &str) -> Result<(u32, u32)> { + let (w, h) = text + .split_once(['x', 'X']) + .with_context(|| format!("geometry {text:?} is not WIDTHxHEIGHT, for example 1280x800"))?; + let width: u32 = w + .trim() + .parse() + .with_context(|| format!("geometry width {w:?} is not a number"))?; + let height: u32 = h + .trim() + .parse() + .with_context(|| format!("geometry height {h:?} is not a number"))?; + if !(640..=3840).contains(&width) || !(480..=2160).contains(&height) { + anyhow::bail!("geometry {width}x{height} is outside 640x480 … 3840x2160"); + } + Ok((width, height)) +} + +/// How long the viewer waits between repaints when nothing is typed. Fast enough +/// that a page transition is not missed, slow enough that a 200×50 diff over SSH +/// costs nothing noticeable. +const FRAME: Duration = Duration::from_millis(400); + +/// How long a pass waits for a keystroke before redrawing. Short enough that +/// typing feels immediate, long enough that an idle session costs no CPU. +const INPUT_POLL: Duration = Duration::from_millis(20); + +/// How often the container is asked whether the portal is still up. It is a +/// subprocess probe, so it is deliberately much rarer than a frame. +const PORTAL_POLL: Duration = Duration::from_secs(3); + +/// Run the whole sign-in session. +pub fn run(options: Options) -> Result<()> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + anyhow::bail!( + "login needs a terminal on both stdin and stdout — run it directly, not through a pipe" + ); + } + + catch_hangups(); + + let xvfb = Xvfb::start(options.width, options.height, options.display)?; + let display_name = xvfb.display(); + let screen = XScreen::connect(&display_name)?; + + // The container gets the display the same way `edge` and `enroll` get the + // user's: the socket directory is bound in and DISPLAY names ours. + let display_info = display::DisplayInfo { + wayland_socket: None, + x11_display: Some(display_name.clone()), + // No cookie: a display we just created has no access control, and handing + // the container the *user's* Xauthority would be a wider grant than this + // needs. + xauthority: None, + has_abstract_x11: false, + }; + + print_keys(&display_name); + ops::portal_start(&display_info).context("could not start the Intune portal")?; + + let outcome = { + let tty = RawTty::enter()?; + view(&screen, &tty) + }; + + // The tty is restored here, so everything below prints normally. + let session = outcome?; + + if session.portal_gone { + eprintln!("The sign-in window closed."); + } else { + eprintln!("Leaving the sign-in — closing the window."); + if let Err(e) = ops::portal_stop() { + eprintln!("Could not close the portal: {e:#}"); + } + } + if let Err(e) = ops::portal_finish() { + eprintln!("Could not return the container to headless: {e:#}"); + } + eprintln!(); + eprintln!("Check what the broker holds now with: intune-container sso-test"); + eprintln!("An app that reads it (teams-lite, a browser with linux-entra-sso) picks"); + eprintln!("the new token up on its own — nothing else to restart."); + Ok(()) +} + +/// Set by [`catch_hangups`] when the session is cut from outside. The viewer polls +/// it so the ordinary teardown runs — the portal is closed, the container goes back +/// to headless and the X server is stopped. +static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +extern "C" fn on_signal(_: nix::libc::c_int) { + INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Turn a hangup into a normal exit. +/// +/// This matters more here than in most commands: a sign-in on a headless box is +/// run over SSH, and a dropped connection sends SIGHUP. Without this the process +/// dies with no unwinding, which leaves an X server running for nobody, a portal +/// drawing into it, and the container still forwarding a display — none of which +/// the next run can tell from a session in progress. +fn catch_hangups() { + use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; + let action = SigAction::new( + SigHandler::Handler(on_signal), + SaFlags::empty(), + SigSet::empty(), + ); + for signal in [Signal::SIGHUP, Signal::SIGTERM, Signal::SIGINT] { + // SAFETY: the handler only stores into an atomic, which is async-signal-safe. + unsafe { + let _ = sigaction(signal, &action); + } + } +} + +/// What the viewer ended on. +struct Session { + /// True when the portal exited by itself (the reader closed the window), + /// false when the reader quit the viewer with the window still open. + portal_gone: bool, +} + +/// Draw the display and forward input until the reader quits or the window goes. +fn view(screen: &XScreen, tty: &RawTty) -> Result { + let (mut cols, mut rows) = tty.size(); + let mut viewport = Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)); + let mut previous: Option> = None; + let mut input = Vec::new(); + let mut help = false; + let mut last_frame = Instant::now() - FRAME; + let mut last_probe = Instant::now(); + let mut portal_seen = false; + + loop { + if INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed) { + return Ok(Session { portal_gone: false }); + } + + // 1. Input first: a keystroke must not wait for the next frame. The poll is + // what keeps the loop responsive without a non-blocking terminal, and its + // timeout is the loop's whole idle cost. + if wait_for_input(INPUT_POLL)? { + let mut chunk = [0u8; 1024]; + match std::io::stdin().read(&mut chunk) { + Ok(0) => return Ok(Session { portal_gone: false }), + Ok(n) => input.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e).context("cannot read the terminal"), + } + } + + let (actions, used) = termview::decode(&input); + input.drain(..used); + let mut dirty = !actions.is_empty(); + for action in actions { + if help { + // Any key dismisses the key list, and the frame under it is + // repainted whole. + help = false; + previous = None; + } + match action { + Action::Quit => return Ok(Session { portal_gone: false }), + Action::Help => { + help = true; + } + Action::Redraw => previous = None, + Action::Fit => { + viewport = Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)) + } + Action::Actual => { + viewport.scale = 1.0; + viewport.clamp(screen.width, screen.height, cols, picture_rows(rows)); + } + Action::ZoomIn => { + viewport.zoom(0.5, screen.width, screen.height, cols, picture_rows(rows)) + } + Action::ZoomOut => { + viewport.zoom(2.0, screen.width, screen.height, cols, picture_rows(rows)) + } + Action::Pan(dx, dy) => viewport.pan( + dx as f32, + dy as f32, + screen.width, + screen.height, + cols, + picture_rows(rows), + ), + Action::Type(c) => { + screen.place_and_focus()?; + if !screen.type_char(c)? { + tty.status(&format!("this display's keyboard cannot type {c:?}"))?; + } + } + Action::Press(key) => { + screen.place_and_focus()?; + screen.press(key)?; + } + Action::Click { col, row, down } => { + if row < picture_rows(rows) { + let (x, y) = viewport.pixel_at(col, row); + screen.click(x, y, down)?; + // A click is how a window gets the focus with no window + // manager around, so let it, then take it back for keys. + if !down { + screen.place_and_focus()?; + } + } + } + } + } + + // 2. A resized terminal invalidates the grid and the fit. + let (c, r) = tty.size(); + if (c, r) != (cols, rows) { + cols = c; + rows = r; + viewport.clamp(screen.width, screen.height, cols, picture_rows(rows)); + previous = None; + dirty = true; + } + + // 3. Has the window come or gone? + if last_probe.elapsed() >= PORTAL_POLL { + last_probe = Instant::now(); + let running = ops::portal_is_running(); + if running { + portal_seen = true; + } else if portal_seen { + return Ok(Session { portal_gone: true }); + } + } + + // 4. Paint. + if dirty || last_frame.elapsed() >= FRAME { + last_frame = Instant::now(); + // Before the picture, not only before a keystroke: a window that has + // just appeared has to be moved into view and given the focus, and the + // reader may look at it for a while before typing anything. + screen.place_and_focus()?; + let frame = screen.capture()?; + let cells = termview::sample(&frame, &viewport, cols, picture_rows(rows)); + let mut out = termview::paint(previous.as_deref(), &cells, cols, picture_rows(rows)); + previous = Some(cells); + if help { + out.push_str(&help_overlay(cols)); + previous = None; + } + out.push_str(&status_line(&viewport, &frame, screen, cols, rows)); + tty.write(&out)?; + } + + } +} + +/// Is there something to read on the terminal within `timeout`? +/// +/// `Ok(false)` on a signal as well as on a timeout: the caller re-checks +/// [`INTERRUPTED`] at the top of every pass, so an interrupted poll is simply the +/// end of this pass. +fn wait_for_input(timeout: Duration) -> Result { + use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; + use std::os::fd::AsFd; + + let stdin = std::io::stdin(); + let mut fds = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)]; + let millis = timeout.as_millis().min(u16::MAX as u128) as u16; + match poll(&mut fds, PollTimeout::from(millis)) { + Ok(ready) => Ok(ready > 0), + Err(nix::errno::Errno::EINTR) => Ok(false), + Err(e) => Err(e).context("cannot wait on the terminal"), + } +} + +/// Rows the picture gets: everything but the status line. +fn picture_rows(rows: u16) -> u16 { + rows.saturating_sub(1).max(1) +} + +/// The bar along the bottom: what is on the display, and how to drive it. +fn status_line( + viewport: &Viewport, + frame: &Frame, + screen: &XScreen, + cols: u16, + rows: u16, +) -> String { + let zoom = if viewport.scale <= 1.001 { + format!("×{:.0}", 1.0 / viewport.scale) + } else { + format!("1:{:.1}", viewport.scale) + }; + let state = if screen.has_window() { + "sign-in window" + } else { + "waiting for the window…" + }; + let right = format!("{} {}×{} {} ", zoom, frame.width, frame.height, state); + format!( + "\x1b[{};1H\x1b[7m\x1b[K{}\x1b[0m", + rows, + status_text(&right, cols) + ) +} + +/// The status bar's text: the key hints on the left, `right` against the right +/// edge, and spaces between. +/// +/// The hints are what gives way when the terminal is narrow — they are a reminder, +/// while the zoom and the window state are the session's only feedback. Without +/// this the two halves ran together on an 80-column terminal ("Ctrl+Q quit1:17.4"). +fn status_text(right: &str, cols: u16) -> String { + const HINTS: &str = " F1 keys · F2/F3 zoom · F4 fit · Ctrl+arrows pan · Ctrl+Q quit"; + let cols = cols as usize; + let right_width = right.chars().count(); + if right_width >= cols { + return right.chars().take(cols).collect(); + } + // At least two spaces between the halves, so they never read as one word. + let room = cols - right_width; + let hints: String = HINTS.chars().take(room.saturating_sub(2)).collect(); + let pad = room - hints.chars().count(); + format!("{hints}{}{right}", " ".repeat(pad)) +} + +/// The key list, drawn over the top-left of the picture. +fn help_overlay(cols: u16) -> String { + let width = termview::HELP + .iter() + .map(|(k, v)| k.chars().count() + v.chars().count() + 3) + .max() + .unwrap_or(40) + .min(cols as usize); + let mut out = String::from("\x1b[1;1H\x1b[7m"); + for (row, (keys, what)) in termview::HELP.iter().enumerate() { + let line = format!(" {keys} {what}"); + let line: String = line.chars().take(width).collect(); + out.push_str(&format!( + "\x1b[{};1H {:width$} ", + row + 1, + line, + width = width + )); + } + out.push_str("\x1b[0m"); + out +} + +/// The key list, printed before the viewer takes the screen. Shown once, because +/// a reader who has never used this needs it before the picture arrives — and the +/// portal takes up to half a minute to draw its first window. +fn print_keys(display: &str) { + eprintln!(); + eprintln!("The sign-in window will be drawn in this terminal (display {display})."); + eprintln!(); + for (keys, what) in termview::HELP { + eprintln!(" {keys:<20} {what}"); + } + eprintln!(); + eprintln!("Type your address, press Enter, type your password, press Enter. When a"); + eprintln!("two-digit number appears, zoom in with F2 and enter it in Authenticator."); + eprintln!(); +} + +/// The terminal, in raw mode with the alternate screen and mouse reporting on. +/// Everything it turns on is turned off when it is dropped — including on the +/// error paths, which is why the viewer never leaves a terminal without an echo. +struct RawTty { + original: Termios, +} + +impl RawTty { + fn enter() -> Result { + let original = + termios::tcgetattr(std::io::stdin()).context("cannot read the terminal settings")?; + let mut raw = original.clone(); + termios::cfmakeraw(&mut raw); + termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &raw) + .context("cannot put the terminal in raw mode")?; + + // The terminal stays BLOCKING, and input is polled instead. O_NONBLOCK on + // stdin also applies to stdout — one terminal, one open file description — + // so a frame larger than the tty's buffer came back `EAGAIN` and the + // session died with "Resource temporarily unavailable" mid-sign-in. That is + // exactly the shape of failure that only appears over a slow SSH link. + let tty = Self { original }; + // Alternate screen, cursor hidden, SGR mouse reporting on. + tty.write("\x1b[?1049h\x1b[2J\x1b[?25l\x1b[?1000h\x1b[?1006h")?; + Ok(tty) + } + + fn write(&self, text: &str) -> Result<()> { + let mut out = std::io::stdout(); + out.write_all(text.as_bytes())?; + out.flush()?; + Ok(()) + } + + /// A one-off message on the status line, for something the reader has to know + /// now (a character this keyboard cannot produce). + fn status(&self, message: &str) -> Result<()> { + let (_, rows) = self.size(); + self.write(&format!("\x1b[{rows};1H\x1b[7m\x1b[K {message}\x1b[0m")) + } + + fn size(&self) -> (u16, u16) { + terminal_size() + } +} + +impl Drop for RawTty { + fn drop(&mut self) { + let _ = self.write("\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"); + let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &self.original); + } +} + +/// The terminal's size in cells, or a conservative default when it has none. +fn terminal_size() -> (u16, u16) { + let mut size: nix::libc::winsize = unsafe { std::mem::zeroed() }; + let ok = unsafe { nix::libc::ioctl(0, nix::libc::TIOCGWINSZ, &mut size) } == 0; + if ok && size.ws_col > 0 && size.ws_row > 1 { + (size.ws_col, size.ws_row) + } else { + (80, 24) + } +} + +/// `std::io::IsTerminal`, imported here so `run` reads plainly. +use std::io::IsTerminal; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_status_line_fills_the_width_exactly() { + let right = "1:17.4 1280×800 sign-in window "; + for cols in [10u16, 34, 40, 80, 120, 200] { + let line = status_text(right, cols); + assert_eq!( + line.chars().count(), + cols as usize, + "cols {cols} produced {line:?}" + ); + } + } + + #[test] + fn the_hints_give_way_before_the_state_does() { + let right = "1:17.4 1280×800 sign-in window "; + // 80 columns is the case that ran the two halves together. + let line = status_text(right, 80); + assert!(line.ends_with(right), "the state was truncated: {line:?}"); + assert!(line.contains(" "), "no gap between the halves: {line:?}"); + assert!(!line.contains("quit1:"), "the halves still collide: {line:?}"); + // Narrower than the state alone: the state wins and nothing panics. + let line = status_text(right, 12); + assert_eq!(line.chars().count(), 12); + } + + #[test] + fn the_picture_leaves_room_for_the_status_line() { + assert_eq!(picture_rows(50), 49); + // A one-row terminal still gets a picture row rather than zero, so the + // renderer is never asked for an empty grid. + assert_eq!(picture_rows(1), 1); + assert_eq!(picture_rows(0), 1); + } + + #[test] + fn the_default_display_keeps_the_desktop_layout() { + let options = Options::default(); + assert!(options.width >= 1280 && options.height >= 800); + // The default must be a value the parser also accepts, or `--geometry` + // could not spell the default back. + assert_eq!( + parse_geometry("1280x800").unwrap(), + (options.width, options.height) + ); + } + + #[test] + fn a_geometry_is_read_or_refused_with_a_reason() { + assert_eq!(parse_geometry("1024x768").unwrap(), (1024, 768)); + assert_eq!(parse_geometry(" 1600 X 900 ").unwrap(), (1600, 900)); + for bad in ["1280", "1280*800", "axb", "", "320x240", "8000x800"] { + assert!(parse_geometry(bad).is_err(), "{bad:?} was accepted"); + } + } +} diff --git a/src/main.rs b/src/main.rs index 4ebdec9..55193eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use std::io::IsTerminal; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use intune_container::{autostart, config, doctor, ops}; +use intune_container::{autostart, config, doctor, login, ops}; #[derive(Parser)] #[command( @@ -80,6 +80,16 @@ enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// Sign in to Entra ID from this terminal (no screen, no VNC needed) + Login { + /// Size of the private display the sign-in is drawn on + #[arg(long, default_value = "1280x800", value_name = "WxH")] + geometry: String, + + /// Use this X display number instead of the first free one + #[arg(long, value_name = "N")] + display: Option, + }, /// Stop the container Stop, /// Show container status and detected display info @@ -222,6 +232,7 @@ fn main() -> Result<()> { Command::Enroll { image } => cmd_enroll(image)?, Command::Start => cmd_start()?, Command::Edge { verbose, args } => ops::edge(verbose, &args)?, + Command::Login { geometry, display } => cmd_login(&geometry, display)?, Command::Stop => ops::stop()?, Command::Status => cmd_status()?, Command::Autostart { action } => match action { @@ -407,6 +418,16 @@ fn cmd_start() -> Result<()> { Ok(()) } +/// Sign in interactively with the window drawn in this terminal. +fn cmd_login(geometry: &str, display: Option) -> Result<()> { + let (width, height) = login::parse_geometry(geometry)?; + login::run(login::Options { + width, + height, + display, + }) +} + fn cmd_detach_display() -> Result<()> { ops::detach_display()?; eprintln!("✓ Host display detached — the container is headless again."); diff --git a/src/ops.rs b/src/ops.rs index 143ae96..52c8fd8 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -78,21 +78,35 @@ fn locked_ensure_running(config: &mut config::Config, with_display: bool) -> Res /// /// Callers must hold the lifecycle lock (use [`locked_ensure_running`]). fn ensure_running(config: &mut config::Config, with_display: bool) -> Result<()> { + ensure_running_on(config, with_display, &display::DisplayInfo::detect()) +} + +/// [`ensure_running`] against a display the caller names rather than the one this +/// process inherited. +/// +/// `login` needs this: its display is a private Xvfb it just created, so +/// detection — which reads `$DISPLAY` — would find the user's session or nothing +/// at all. Attaching binds the whole `/tmp/.X11-unix` directory, so a container +/// that is *already* forwarding some display can reach the new socket without a +/// restart; that is why this does not force one. +fn ensure_running_on( + config: &mut config::Config, + with_display: bool, + display: &display::DisplayInfo, +) -> Result<()> { if !config.initialized { anyhow::bail!("Not set up yet. Run: intune-container enroll"); } - let display = display::DisplayInfo::detect(); - if backend::is_running(config) { if with_display && !config.display_forwarding { - backend::attach_display(config, &display)?; + backend::attach_display(config, display)?; } return Ok(()); } debug!("Container not running — starting it..."); - boot(config, &display, with_display)?; + boot(config, display, with_display)?; Ok(()) } @@ -165,6 +179,61 @@ pub fn enroll() -> Result { Ok(closed) } +// ===== Login (terminal sign-in) ===== + +/// Bring the container up with `display` attached and launch the Intune portal on +/// it, in the background. +/// +/// The three halves of a terminal sign-in are split so [`crate::login`] can drive +/// the window between them: this starts it, [`portal_is_running`] says whether it +/// is still there, and [`portal_finish`] puts the container back the way it was. +/// `enroll` does the same three things in one call because it has a real screen +/// to wait on. +pub fn portal_start(display: &display::DisplayInfo) -> Result<()> { + let mut config = + config::Config::load().context("Not set up yet. Run: intune-container enroll")?; + { + let _lock = lock::LifecycleLock::acquire()?; + ensure_running_on(&mut config, true, display)?; + } + + // Gate on the container's own services, exactly as enrollment does: a portal + // started before the broker is up dies with "Message recipient disconnected". + backend::wait_until_portal_ready(&config); + + info!("Starting the Intune portal — its window can take up to ~30s the first time."); + backend::exec(&config, "intune-portal", None, display) +} + +/// Whether the portal window is still open. False also when the container is not +/// running at all, which is the same thing from the viewer's point of view. +pub fn portal_is_running() -> bool { + let Ok(config) = config::Config::load() else { + return false; + }; + backend::probe( + &config, + "pgrep -u \"$(id -u)\" -x intune-portal >/dev/null 2>&1", + ) == 0 +} + +/// Close the portal window. Used when the reader quits the viewer with the window +/// still open: the display is about to be destroyed, and a portal drawing into a +/// display that no longer exists is a process nobody can reach. +pub fn portal_stop() -> Result<()> { + let config = config::Config::load().context("Failed to load configuration")?; + // TERM, never KILL: the portal writes the broker's account state on the way + // out, and half-written state is worse than a window left open. + backend::probe(&config, "pkill -u \"$(id -u)\" -x intune-portal"); + Ok(()) +} + +/// Return the container to headless isolation after a terminal sign-in. +pub fn portal_finish() -> Result<()> { + let mut config = config::Config::load().context("Failed to load configuration")?; + auto_detach_after_gui(&mut config) +} + // ===== Edge ===== /// Launch Microsoft Edge inside the container (a GUI flow: forwards the real diff --git a/src/termview.rs b/src/termview.rs new file mode 100644 index 0000000..6ec7060 --- /dev/null +++ b/src/termview.rs @@ -0,0 +1,767 @@ +//! The pure half of the terminal viewer: pixels in, ANSI out, and bytes in, +//! intentions out. +//! +//! WHY IT EXISTS. Signing in to Entra ID needs a *visible* browser: the identity +//! broker knows no device-code flow on Linux (its own binary says +//! "AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform") and +//! renders the sign-in page in an embedded WebKitGTK window on an X display. On a +//! headless server there is nothing to look at, so the choice used to be a VNC +//! viewer or nothing. This module draws that window in the terminal instead. +//! +//! It holds no X11 and no terminal I/O — [`crate::xscreen`] talks to the display +//! and [`crate::login`] owns the tty — so every rule below is unit-tested: +//! +//! * one cell is one `▀`, foreground = the top half, background = the bottom +//! half, which is why a cell covers `scale` pixels across and `2 × scale` down +//! (a terminal cell is about twice as tall as it is wide, so pixels stay +//! square); +//! * a cell averages the pixels it covers rather than sampling one of them, +//! because a thin glyph stroke disappears under nearest-neighbour and the whole +//! point is to read a number-matching code; +//! * a repaint emits only the cells that changed, and a colour only when it +//! differs from the last one written, because a full 200×50 frame is 400 kB of +//! escape codes and this runs over SSH. + +/// One captured X screen: 8-bit RGB, row-major, `width × height` pixels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub width: u32, + pub height: u32, + /// `3 × width × height` bytes, R, G, B. + pub rgb: Vec, +} + +impl Frame { + /// A frame of one flat colour. Used by the tests, and by the viewer before + /// the first capture arrives. + pub fn flat(width: u32, height: u32, colour: [u8; 3]) -> Self { + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for _ in 0..width * height { + rgb.extend_from_slice(&colour); + } + Self { width, height, rgb } + } + + fn pixel(&self, x: u32, y: u32) -> [u8; 3] { + if x >= self.width || y >= self.height { + return [0, 0, 0]; + } + let i = ((y * self.width + x) * 3) as usize; + match self.rgb.get(i..i + 3) { + Some(p) => [p[0], p[1], p[2]], + None => [0, 0, 0], + } + } + + /// The mean colour of the `w × h` source rectangle at (`x`, `y`), clipped to + /// the frame. Returns black for a rectangle entirely outside it, which is what + /// the letterboxing around a zoomed-out screen should look like. + fn average(&self, x: f32, y: f32, w: f32, h: f32) -> [u8; 3] { + let x0 = x.floor().max(0.0) as u32; + let y0 = y.floor().max(0.0) as u32; + let x1 = ((x + w).ceil() as i64).clamp(0, self.width as i64) as u32; + let y1 = ((y + h).ceil() as i64).clamp(0, self.height as i64) as u32; + if x1 <= x0 || y1 <= y0 { + return [0, 0, 0]; + } + // One pixel is the common case (scale 1, no zoom out): skip the sums. + if x1 - x0 == 1 && y1 - y0 == 1 { + return self.pixel(x0, y0); + } + let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32); + for py in y0..y1 { + for px in x0..x1 { + let p = self.pixel(px, py); + r += p[0] as u32; + g += p[1] as u32; + b += p[2] as u32; + n += 1; + } + } + [(r / n) as u8, (g / n) as u8, (b / n) as u8] + } +} + +/// Which part of the frame the terminal shows, and how magnified. +/// +/// `scale` is source pixels per cell across (and per half-cell down), so a +/// smaller number is a closer view: 1.0 shows every pixel and is what makes a +/// two-digit code legible; `fit` is whatever puts the whole screen on the tty. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Viewport { + pub scale: f32, + pub ox: f32, + pub oy: f32, +} + +/// The scale that fits `width × height` pixels into `cols × rows` cells. +pub fn fit_scale(width: u32, height: u32, cols: u16, rows: u16) -> f32 { + if cols == 0 || rows == 0 { + return 1.0; + } + let across = width as f32 / cols as f32; + let down = height as f32 / (rows as f32 * 2.0); + across.max(down).max(0.01) +} + +impl Viewport { + /// The whole screen, centred. + pub fn fitted(frame_w: u32, frame_h: u32, cols: u16, rows: u16) -> Self { + let scale = fit_scale(frame_w, frame_h, cols, rows); + let mut vp = Self { + scale, + ox: 0.0, + oy: 0.0, + }; + vp.centre(frame_w, frame_h, cols, rows); + vp + } + + /// Centre the view on the frame, then clamp. Called when fitting and after a + /// zoom, so zooming out never leaves the picture stuck in a corner. + pub fn centre(&mut self, frame_w: u32, frame_h: u32, cols: u16, rows: u16) { + let (vw, vh) = self.covered(cols, rows); + self.ox = (frame_w as f32 - vw) / 2.0; + self.oy = (frame_h as f32 - vh) / 2.0; + self.clamp(frame_w, frame_h, cols, rows); + } + + /// How many source pixels the terminal covers at this scale. + pub fn covered(&self, cols: u16, rows: u16) -> (f32, f32) { + ( + cols as f32 * self.scale, + rows as f32 * 2.0 * self.scale, + ) + } + + /// Keep the view over the frame: no scrolling into the void, and a view + /// larger than the frame is centred instead of pinned to the origin. + pub fn clamp(&mut self, frame_w: u32, frame_h: u32, cols: u16, rows: u16) { + let (vw, vh) = self.covered(cols, rows); + self.ox = clamp_axis(self.ox, vw, frame_w as f32); + self.oy = clamp_axis(self.oy, vh, frame_h as f32); + } + + /// Multiply the scale, keeping the centre of the view where it was, so + /// zooming in goes *towards* what the reader is looking at. + pub fn zoom(&mut self, factor: f32, frame_w: u32, frame_h: u32, cols: u16, rows: u16) { + let (vw, vh) = self.covered(cols, rows); + let (cx, cy) = (self.ox + vw / 2.0, self.oy + vh / 2.0); + self.scale = (self.scale * factor).clamp(0.25, 64.0); + let (nw, nh) = self.covered(cols, rows); + self.ox = cx - nw / 2.0; + self.oy = cy - nh / 2.0; + self.clamp(frame_w, frame_h, cols, rows); + } + + /// Pan by a fraction of the visible area (a third of a screen per keypress). + pub fn pan(&mut self, dx: f32, dy: f32, frame_w: u32, frame_h: u32, cols: u16, rows: u16) { + let (vw, vh) = self.covered(cols, rows); + self.ox += dx * vw / 3.0; + self.oy += dy * vh / 3.0; + self.clamp(frame_w, frame_h, cols, rows); + } + + /// The frame pixel under a terminal cell, for turning a click into a pointer + /// position. `row` is a whole cell; the pointer lands in the middle of it. + pub fn pixel_at(&self, col: u16, row: u16) -> (i16, i16) { + let x = self.ox + (col as f32 + 0.5) * self.scale; + let y = self.oy + (row as f32 + 1.0) * self.scale; + (x.round() as i16, y.round() as i16) + } +} + +fn clamp_axis(offset: f32, view: f32, frame: f32) -> f32 { + if view >= frame { + // The whole axis is visible: centre it (a negative offset letterboxes). + (frame - view) / 2.0 + } else { + offset.clamp(0.0, frame - view) + } +} + +/// One character cell: the colour of its top half and of its bottom half. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Cell { + pub top: [u8; 3], + pub bottom: [u8; 3], +} + +/// Sample the frame into a `cols × rows` grid of cells, row-major. +pub fn sample(frame: &Frame, vp: &Viewport, cols: u16, rows: u16) -> Vec { + let mut cells = Vec::with_capacity(cols as usize * rows as usize); + for row in 0..rows { + for col in 0..cols { + let x = vp.ox + col as f32 * vp.scale; + let y = vp.oy + row as f32 * 2.0 * vp.scale; + cells.push(Cell { + top: frame.average(x, y, vp.scale, vp.scale), + bottom: frame.average(x, y + vp.scale, vp.scale, vp.scale), + }); + } + } + cells +} + +/// The upper half block: its foreground paints the top pixel, its background the +/// bottom one. +const BLOCK: char = '\u{2580}'; + +/// Paint `next`, writing only what changed since `prev`. +/// +/// `prev` of a different length (a resized terminal) is ignored, so the caller +/// can hand over whatever it has without checking. +pub fn paint(prev: Option<&[Cell]>, next: &[Cell], cols: u16, rows: u16) -> String { + let prev = prev.filter(|p| p.len() == next.len()); + let mut out = String::new(); + // The last colours and position written, so an unchanged run costs one char. + let mut fg: Option<[u8; 3]> = None; + let mut bg: Option<[u8; 3]> = None; + let mut at: Option<(u16, u16)> = None; + + for row in 0..rows { + for col in 0..cols { + let i = row as usize * cols as usize + col as usize; + let Some(cell) = next.get(i) else { continue }; + if let Some(p) = prev { + if p[i] == *cell { + continue; + } + } + if at != Some((row, col)) { + out.push_str(&format!("\x1b[{};{}H", row + 1, col + 1)); + } + if fg != Some(cell.top) { + let [r, g, b] = cell.top; + out.push_str(&format!("\x1b[38;2;{r};{g};{b}m")); + fg = Some(cell.top); + } + if bg != Some(cell.bottom) { + let [r, g, b] = cell.bottom; + out.push_str(&format!("\x1b[48;2;{r};{g};{b}m")); + bg = Some(cell.bottom); + } + out.push(BLOCK); + at = Some((row, col + 1)); + } + } + out +} + +/// An X keysym the viewer can send. Only the keys a sign-in page needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Return, + Tab, + BackTab, + BackSpace, + Delete, + Escape, + Left, + Right, + Up, + Down, + Home, + End, + PageUp, + PageDown, +} + +impl Key { + /// The X11 keysym. `BackTab` is Tab plus Shift, which the sender applies. + pub fn keysym(self) -> u32 { + match self { + Key::Return => 0xff0d, + Key::Tab | Key::BackTab => 0xff09, + Key::BackSpace => 0xff08, + Key::Delete => 0xffff, + Key::Escape => 0xff1b, + Key::Left => 0xff51, + Key::Up => 0xff52, + Key::Right => 0xff53, + Key::Down => 0xff54, + Key::Home => 0xff50, + Key::End => 0xff57, + Key::PageUp => 0xff55, + Key::PageDown => 0xff56, + } + } +} + +/// What a keystroke means. Viewer commands are F-keys and control characters — +/// never a printable one, which belongs to the page being typed into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Send a character to the X display. + Type(char), + /// Send a named key. + Press(Key), + /// Left button at a terminal cell (`down` false is the release). + Click { col: u16, row: u16, down: bool }, + ZoomIn, + ZoomOut, + Fit, + Actual, + Pan(i8, i8), + Redraw, + Help, + Quit, +} + +/// Decode as many actions as the buffer holds, returning them and how many bytes +/// were consumed. A trailing partial escape sequence is left for the next read, +/// so a split arrow key is never mistaken for an Escape. +pub fn decode(buf: &[u8]) -> (Vec, usize) { + let mut actions = Vec::new(); + let mut i = 0; + while i < buf.len() { + let b = buf[i]; + match b { + 0x1b => match decode_escape(&buf[i..]) { + Escape::Incomplete => return (actions, i), + Escape::Unknown(n) => i += n, + Escape::Action(a, n) => { + actions.push(a); + i += n; + } + }, + 0x0d | 0x0a => { + actions.push(Action::Press(Key::Return)); + i += 1; + } + 0x09 => { + actions.push(Action::Press(Key::Tab)); + i += 1; + } + 0x7f | 0x08 => { + actions.push(Action::Press(Key::BackSpace)); + i += 1; + } + // Ctrl+Q quit, Ctrl+R redraw, Ctrl+U/Ctrl+D zoom, Ctrl+W fit, + // Ctrl+G help. Chosen from characters a text field never needs. + 0x11 => { + actions.push(Action::Quit); + i += 1; + } + 0x12 => { + actions.push(Action::Redraw); + i += 1; + } + 0x15 => { + actions.push(Action::ZoomIn); + i += 1; + } + 0x04 => { + actions.push(Action::ZoomOut); + i += 1; + } + 0x17 => { + actions.push(Action::Fit); + i += 1; + } + 0x07 => { + actions.push(Action::Help); + i += 1; + } + 0x20..=0x7e => { + actions.push(Action::Type(b as char)); + i += 1; + } + // Anything else (a stray control byte, a UTF-8 continuation) is + // dropped rather than guessed at. + _ => i += 1, + } + } + (actions, i) +} + +enum Escape { + Action(Action, usize), + Unknown(usize), + Incomplete, +} + +fn decode_escape(buf: &[u8]) -> Escape { + // A lone ESC with nothing behind it is the Escape key. The caller polls with + // a timeout, so a real arrow key arrives in one read with its bracket. + if buf.len() == 1 { + return Escape::Action(Action::Press(Key::Escape), 1); + } + match buf[1] { + b'[' => decode_csi(buf), + b'O' if buf.len() >= 3 => match buf[2] { + // SS3: the F-keys and, on some terminals, the arrows. + b'P' => Escape::Action(Action::Help, 3), + b'Q' => Escape::Action(Action::ZoomIn, 3), + b'R' => Escape::Action(Action::ZoomOut, 3), + b'S' => Escape::Action(Action::Fit, 3), + b'A' => Escape::Action(Action::Press(Key::Up), 3), + b'B' => Escape::Action(Action::Press(Key::Down), 3), + b'C' => Escape::Action(Action::Press(Key::Right), 3), + b'D' => Escape::Action(Action::Press(Key::Left), 3), + _ => Escape::Unknown(3), + }, + b'O' => Escape::Incomplete, + // ESC followed by a printable character is Alt+key on most terminals. + // Nothing here wants it, and swallowing both bytes keeps a stray Alt + // chord from typing its letter into the page. + _ => Escape::Unknown(2), + } +} + +fn decode_csi(buf: &[u8]) -> Escape { + // CSI mouse in SGR form: ESC [ < b ; x ; y (M|m). + if buf.len() >= 3 && buf[2] == b'<' { + return decode_mouse(buf); + } + let mut end = 2; + while end < buf.len() && !(0x40..=0x7e).contains(&buf[end]) { + end += 1; + } + if end >= buf.len() { + return Escape::Incomplete; + } + let params = &buf[2..end]; + let final_byte = buf[end]; + let n = end + 1; + // A modifier is the second parameter: ";5" is Ctrl, ";2" is Shift. + let ctrl = params.ends_with(b";5"); + let shift = params.ends_with(b";2"); + let action = match final_byte { + b'A' if ctrl => Action::Pan(0, -1), + b'B' if ctrl => Action::Pan(0, 1), + b'C' if ctrl => Action::Pan(1, 0), + b'D' if ctrl => Action::Pan(-1, 0), + b'A' => Action::Press(Key::Up), + b'B' => Action::Press(Key::Down), + b'C' => Action::Press(Key::Right), + b'D' => Action::Press(Key::Left), + b'H' => Action::Press(Key::Home), + b'F' => Action::Press(Key::End), + b'Z' => Action::Press(Key::BackTab), + b'~' => match first_param(params) { + Some(1) | Some(7) => Action::Press(Key::Home), + Some(2) => Action::Press(Key::Escape), + Some(3) => Action::Press(Key::Delete), + Some(4) | Some(8) => Action::Press(Key::End), + Some(5) => Action::Press(Key::PageUp), + Some(6) => Action::Press(Key::PageDown), + Some(11) | Some(23) => Action::Help, + Some(12) | Some(24) => Action::ZoomIn, + Some(13) => Action::ZoomOut, + Some(14) => Action::Fit, + Some(15) => Action::Redraw, + Some(17) => Action::Actual, + Some(_) | None => return Escape::Unknown(n), + }, + b'M' | b'm' => return Escape::Unknown(n), + _ => return Escape::Unknown(n), + }; + // Shift+Tab arrives as CSI Z on most terminals and as CSI 1;2 I on some. + let action = if shift && final_byte == b'I' { + Action::Press(Key::BackTab) + } else { + action + }; + Escape::Action(action, n) +} + +fn first_param(params: &[u8]) -> Option { + let text = std::str::from_utf8(params).ok()?; + text.split(';').next()?.parse().ok() +} + +fn decode_mouse(buf: &[u8]) -> Escape { + let mut end = 3; + while end < buf.len() && buf[end] != b'M' && buf[end] != b'm' { + end += 1; + } + if end >= buf.len() { + return Escape::Incomplete; + } + let n = end + 1; + let Ok(text) = std::str::from_utf8(&buf[3..end]) else { + return Escape::Unknown(n); + }; + let mut parts = text.split(';'); + let button: u32 = match parts.next().and_then(|p| p.parse().ok()) { + Some(b) => b, + None => return Escape::Unknown(n), + }; + let col: u16 = match parts.next().and_then(|p| p.parse().ok()) { + Some(c) => c, + None => return Escape::Unknown(n), + }; + let row: u16 = match parts.next().and_then(|p| p.parse().ok()) { + Some(r) => r, + None => return Escape::Unknown(n), + }; + // Left button only, press (M) and release (m). Wheel (64/65) and drag (32+) + // are ignored: a page that needs scrolling has PageUp, and a drag has no use + // on a sign-in form. + if button != 0 { + return Escape::Unknown(n); + } + Escape::Action( + Action::Click { + // The terminal counts from 1; the viewport counts cells from 0. + col: col.saturating_sub(1), + row: row.saturating_sub(1), + down: buf[end] == b'M', + }, + n, + ) +} + +/// The key map, shown on F1 and once at startup. One line per binding, so the +/// caller can frame it however it likes. +pub const HELP: &[(&str, &str)] = &[ + ("type / Enter / Tab", "goes to the sign-in page"), + ("click", "moves and clicks the pointer there"), + ("F2 / Ctrl+U", "zoom in — use it to read the Authenticator number"), + ("F3 / Ctrl+D", "zoom out"), + ("F4 / Ctrl+W", "fit the whole screen"), + ("F6", "actual size (one pixel per half cell)"), + ("Ctrl+arrows", "pan"), + ("F5 / Ctrl+R", "repaint"), + ("F1 / Ctrl+G", "this list"), + ("Ctrl+Q", "quit (the sign-in keeps running)"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_cell_averages_the_pixels_it_covers() { + // Two columns: black and white. One cell over both is mid grey — under + // nearest-neighbour it would be one or the other, and a thin glyph + // stroke would vanish. + let mut frame = Frame::flat(2, 2, [0, 0, 0]); + for y in 0..2 { + let i = ((y * 2 + 1) * 3) as usize; + frame.rgb[i] = 255; + frame.rgb[i + 1] = 255; + frame.rgb[i + 2] = 255; + } + let vp = Viewport { + scale: 2.0, + ox: 0.0, + oy: 0.0, + }; + let cells = sample(&frame, &vp, 1, 1); + assert_eq!(cells[0].top, [127, 127, 127]); + } + + #[test] + fn a_cell_holds_two_rows_of_pixels() { + // Top row red, bottom row blue: one cell at scale 1 must carry both. + let mut frame = Frame::flat(1, 2, [255, 0, 0]); + frame.rgb[3] = 0; + frame.rgb[5] = 255; + let vp = Viewport { + scale: 1.0, + ox: 0.0, + oy: 0.0, + }; + let cells = sample(&frame, &vp, 1, 1); + assert_eq!(cells[0].top, [255, 0, 0]); + assert_eq!(cells[0].bottom, [0, 0, 255]); + } + + #[test] + fn fit_shows_the_whole_screen() { + // 1280x800 into 200x50 cells: 200 cells * scale >= 1280 and + // 100 half-cells * scale >= 800. + let scale = fit_scale(1280, 800, 200, 50); + assert!(200.0 * scale >= 1280.0, "scale {scale} clips horizontally"); + assert!(100.0 * scale >= 800.0, "scale {scale} clips vertically"); + // And it is the *tightest* such scale, so nothing is wasted. + assert!(scale <= 8.01, "scale {scale} is looser than it needs to be"); + } + + #[test] + fn a_repaint_writes_only_what_changed() { + let cols = 4; + let rows = 2; + let a = vec![Cell::default(); (cols * rows) as usize]; + let mut b = a.clone(); + b[5].top = [1, 2, 3]; + let out = paint(Some(&a), &b, cols, rows); + // One cell means one block character, and no other. + assert_eq!(out.matches(BLOCK).count(), 1); + assert!(out.contains("\x1b[2;2H"), "did not address row 2 col 2: {out:?}"); + assert!(out.contains("38;2;1;2;3")); + } + + #[test] + fn a_repaint_of_a_resized_terminal_redraws_everything() { + let old = vec![Cell::default(); 4]; + let new = vec![Cell::default(); 6]; + // A grid of a different size cannot be diffed against, so every cell is + // written rather than none — otherwise a resize would show a stale frame. + let out = paint(Some(&old), &new, 3, 2); + assert_eq!(out.matches(BLOCK).count(), 6); + } + + #[test] + fn a_colour_is_written_once_for_a_run() { + let cells = vec![ + Cell { + top: [9, 9, 9], + bottom: [1, 1, 1], + }; + 3 + ]; + let out = paint(None, &cells, 3, 1); + assert_eq!(out.matches("38;2;9;9;9").count(), 1); + assert_eq!(out.matches("48;2;1;1;1").count(), 1); + assert_eq!(out.matches(BLOCK).count(), 3); + } + + #[test] + fn printable_bytes_are_typed_and_control_bytes_are_commands() { + let (actions, used) = decode(b"ab\x11"); + assert_eq!(used, 3); + assert_eq!( + actions, + vec![Action::Type('a'), Action::Type('b'), Action::Quit] + ); + } + + #[test] + fn an_at_sign_and_a_dot_reach_the_page() { + // The one thing this must never break: typing an email address. + let (actions, _) = decode(b"a.b@c.d"); + let typed: String = actions + .iter() + .filter_map(|a| match a { + Action::Type(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(typed, "a.b@c.d"); + } + + #[test] + fn arrows_go_to_the_page_and_ctrl_arrows_pan() { + let (actions, _) = decode(b"\x1b[A"); + assert_eq!(actions, vec![Action::Press(Key::Up)]); + let (actions, _) = decode(b"\x1b[1;5A"); + assert_eq!(actions, vec![Action::Pan(0, -1)]); + } + + #[test] + fn a_split_escape_sequence_is_not_read_as_escape() { + // The killer bug this guards: half an arrow key arriving in one read. + // Escape must not be sent to the page, and the bytes must be kept. + let (actions, used) = decode(b"\x1b["); + assert!(actions.is_empty()); + assert_eq!(used, 0); + let (actions, used) = decode(b"\x1b[1;5"); + assert!(actions.is_empty()); + assert_eq!(used, 0); + } + + #[test] + fn a_lone_escape_is_the_escape_key() { + let (actions, used) = decode(b"\x1b"); + assert_eq!(actions, vec![Action::Press(Key::Escape)]); + assert_eq!(used, 1); + } + + #[test] + fn a_click_becomes_a_cell_counted_from_zero() { + let (actions, used) = decode(b"\x1b[<0;10;5M"); + assert_eq!(used, 10); + assert_eq!( + actions, + vec![Action::Click { + col: 9, + row: 4, + down: true + }] + ); + let (actions, _) = decode(b"\x1b[<0;10;5m"); + assert_eq!( + actions, + vec![Action::Click { + col: 9, + row: 4, + down: false + }] + ); + } + + #[test] + fn the_wheel_and_a_right_click_are_ignored() { + let (actions, used) = decode(b"\x1b[<64;10;5M"); + assert!(actions.is_empty()); + assert_eq!(used, 11); + let (actions, _) = decode(b"\x1b[<2;10;5M"); + assert!(actions.is_empty()); + } + + #[test] + fn zooming_in_keeps_the_centre_and_clamps_to_the_frame() { + let (w, h, cols, rows) = (1000, 1000, 100, 25); + let mut vp = Viewport::fitted(w, h, cols, rows); + let (vw, vh) = vp.covered(cols, rows); + let centre = (vp.ox + vw / 2.0, vp.oy + vh / 2.0); + vp.zoom(0.5, w, h, cols, rows); + let (nw, nh) = vp.covered(cols, rows); + assert!((vp.ox + nw / 2.0 - centre.0).abs() < 1.0); + assert!((vp.oy + nh / 2.0 - centre.1).abs() < 1.0); + // Zoomed in, the view must stay inside the frame. + assert!(vp.ox >= 0.0 && vp.oy >= 0.0); + assert!(vp.ox + nw <= w as f32 + 0.01); + } + + #[test] + fn panning_stops_at_the_edge() { + let (w, h, cols, rows) = (1000, 1000, 100, 25); + let mut vp = Viewport { + scale: 1.0, + ox: 0.0, + oy: 0.0, + }; + // Enough presses to cross the frame on BOTH axes: one press is a third of + // the visible area, and the visible height here is half the width. + for _ in 0..200 { + vp.pan(-1.0, -1.0, w, h, cols, rows); + } + assert_eq!((vp.ox, vp.oy), (0.0, 0.0)); + for _ in 0..200 { + vp.pan(1.0, 1.0, w, h, cols, rows); + } + let (vw, vh) = vp.covered(cols, rows); + assert!((vp.ox + vw - w as f32).abs() < 0.01); + assert!((vp.oy + vh - h as f32).abs() < 0.01); + } + + #[test] + fn a_view_wider_than_the_screen_is_centred() { + // Zoomed out past the frame: the picture is letterboxed, not pinned. + let mut vp = Viewport { + scale: 10.0, + ox: 0.0, + oy: 0.0, + }; + vp.clamp(100, 100, 100, 25); + assert!(vp.ox < 0.0 && vp.oy < 0.0); + let (vw, _) = vp.covered(100, 25); + assert!((vp.ox + vw / 2.0 - 50.0).abs() < 0.01); + } + + #[test] + fn a_click_maps_back_to_the_pixel_it_covers() { + let vp = Viewport { + scale: 4.0, + ox: 100.0, + oy: 200.0, + }; + // Cell (0,0) is the 4x4 block at (100,200) — the pointer goes to its middle. + assert_eq!(vp.pixel_at(0, 0), (102, 204)); + assert_eq!(vp.pixel_at(10, 5), (142, 224)); + } +} diff --git a/src/xscreen.rs b/src/xscreen.rs new file mode 100644 index 0000000..23db9c3 --- /dev/null +++ b/src/xscreen.rs @@ -0,0 +1,496 @@ +//! The X11 half of the terminal viewer: read a display's pixels, and put keys +//! and clicks back into it. +//! +//! It talks to an X server that [`crate::login`] owns — a private Xvfb, never the +//! user's own session — and it uses two extensions and nothing else: `GetImage` +//! to read the root window, and XTEST to synthesise input. There is no window of +//! our own, no event loop and no compositor: the sign-in window belongs to the +//! container's Intune portal, which draws into the same display. +//! +//! WHY XTEST AND NOT AN EVENT. A key sent as a synthetic `KeyPress` event is +//! rejected by GTK (it checks `send_event`), so the only way to type into a +//! window we do not own is to make the *server* generate the event. That is what +//! XTEST is for, and it is why the display has to be ours: XTEST input goes +//! wherever the input focus is, which on a shared display would be whatever the +//! user was doing. + +use anyhow::{Context, Result}; +use tracing::debug; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + ConfigureWindowAux, ConnectionExt as _, ImageFormat, InputFocus, MapState, Window, +}; +use x11rb::protocol::xtest::ConnectionExt as _; +use x11rb::rust_connection::RustConnection; + +use crate::termview::{Frame, Key}; + +/// X11 event types, as XTEST's `fake_input` wants them. +const KEY_PRESS: u8 = 2; +const KEY_RELEASE: u8 = 3; +const BUTTON_PRESS: u8 = 4; +const BUTTON_RELEASE: u8 = 5; +const MOTION_NOTIFY: u8 = 6; + +/// The Shift keysym, needed for every capital letter and every symbol on a +/// shifted key — an email address and a password are full of them. +const SHIFT_L: u32 = 0xffe1; + +/// A connection to one X display, with the pixel format and the keyboard map +/// resolved once. +pub struct XScreen { + conn: RustConnection, + root: Window, + pub width: u32, + pub height: u32, + /// Bits per pixel of the root depth's image format, and where each channel + /// sits in the little-endian word. Read from the server, never assumed: + /// Xvfb's default depth is 24 in a 32-bit word, but a `-depth 16` display + /// packs 5-6-5 and reading it as BGRX would give a green picture. + bits_per_pixel: u8, + channels: [Channel; 3], + keymap: Keymap, + shift: u8, +} + +#[derive(Debug, Clone, Copy)] +struct Channel { + shift: u32, + max: u32, +} + +impl Channel { + fn of(mask: u32) -> Self { + Self { + shift: mask.trailing_zeros(), + max: mask >> mask.trailing_zeros(), + } + } + + /// Scale the channel to 0..=255, so a 16-bit display renders with the same + /// brightness as a 24-bit one. + fn value(self, word: u32) -> u8 { + if self.max == 0 { + return 0; + } + let raw = (word >> self.shift) & self.max; + ((raw * 255) / self.max) as u8 + } +} + +/// Keysym → (keycode, needs shift), built from the server's own mapping. +struct Keymap { + min: u8, + per_code: u8, + syms: Vec, +} + +impl Keymap { + /// The keycode that produces `keysym`, and whether Shift is needed for it. + /// Only the first two levels are searched: level 0 and 1 are the unshifted + /// and shifted symbols, and a third-level key needs a modifier this viewer + /// does not send. + fn lookup(&self, keysym: u32) -> Option<(u8, bool)> { + let per = self.per_code as usize; + for (index, chunk) in self.syms.chunks(per).enumerate() { + for level in 0..per.min(2) { + if chunk.get(level) == Some(&keysym) { + let code = self.min as usize + index; + return Some((code as u8, level == 1)); + } + } + } + None + } +} + +impl XScreen { + /// Connect to the named display (for example `":77"`). + /// + /// The parameter is `name` rather than `display` on purpose: a `tracing` macro + /// brings its own `display` value-wrapper into scope, so a variable of that + /// name cannot be logged. + pub fn connect(name: &str) -> Result { + let (conn, screen_num) = x11rb::connect(Some(name)) + .with_context(|| format!("cannot connect to the X display {name}"))?; + let setup = conn.setup(); + let screen = setup + .roots + .get(screen_num) + .context("the X server reported no screen")?; + let root = screen.root; + let width = screen.width_in_pixels as u32; + let height = screen.height_in_pixels as u32; + + let depth = screen.root_depth; + let bits_per_pixel = setup + .pixmap_formats + .iter() + .find(|f| f.depth == depth) + .map(|f| f.bits_per_pixel) + .context("the X server reported no image format for its root depth")?; + let visual = screen + .allowed_depths + .iter() + .flat_map(|d| &d.visuals) + .find(|v| v.visual_id == screen.root_visual) + .context("the X server reported no visual for its root window")?; + let channels = [ + Channel::of(visual.red_mask), + Channel::of(visual.green_mask), + Channel::of(visual.blue_mask), + ]; + + let count = setup.max_keycode - setup.min_keycode + 1; + let mapping = conn + .get_keyboard_mapping(setup.min_keycode, count) + .context("cannot ask the X server for its keyboard map")? + .reply() + .context("the X server refused its keyboard map")?; + let keymap = Keymap { + min: setup.min_keycode, + per_code: mapping.keysyms_per_keycode, + syms: mapping.keysyms, + }; + let shift = keymap + .lookup(SHIFT_L) + .map(|(code, _)| code) + .context("the X keyboard map has no Shift key")?; + + debug!("connected to the X display {name} ({width}x{height}, {bits_per_pixel} bpp)"); + + Ok(Self { + conn, + root, + width, + height, + bits_per_pixel, + channels, + keymap, + shift, + }) + } + + /// Read the whole root window. + /// + /// The root is deliberate: it holds whatever is on the display, including + /// menus and dialogs that are windows of their own, so nothing the reader has + /// to see can hide from the capture. + pub fn capture(&self) -> Result { + let image = self + .conn + .get_image( + ImageFormat::Z_PIXMAP, + self.root, + 0, + 0, + self.width as u16, + self.height as u16, + !0, + ) + .context("cannot ask the X server for the screen")? + .reply() + .context("the X server refused the screen image")?; + Ok(self.decode(&image.data)) + } + + fn decode(&self, data: &[u8]) -> Frame { + let bytes = (self.bits_per_pixel as usize + 7) / 8; + let mut rgb = Vec::with_capacity((self.width * self.height * 3) as usize); + for y in 0..self.height as usize { + for x in 0..self.width as usize { + let i = (y * self.width as usize + x) * bytes; + let word = match data.get(i..i + bytes) { + Some(px) => px + .iter() + .enumerate() + .fold(0u32, |w, (n, b)| w | ((*b as u32) << (8 * n))), + None => 0, + }; + rgb.push(self.channels[0].value(word)); + rgb.push(self.channels[1].value(word)); + rgb.push(self.channels[2].value(word)); + } + } + Frame { + width: self.width, + height: self.height, + rgb, + } + } + + /// Put the topmost real window where it can be read, and give it the + /// keyboard. + /// + /// The display has no window manager on purpose — one more moving part, and + /// the portal shows one window at a time — but that means nothing does the two + /// things a window manager would, and both were real failures: + /// + /// * nothing assigns focus, so XTEST keys went to the root window and + /// vanished; and + /// * nothing places a window, so the portal mapped its dialog at its own + /// coordinates — 615,375 on a 1280×800 display, which put the Sign in button + /// past the bottom edge where no amount of panning could reach it. + /// + /// Moving it to the origin fixes the second: every window then starts at a + /// corner the viewer can always show. It is called before every keystroke + /// because the password step is a *new* window, which must be placed and + /// focused in its turn. + pub fn place_and_focus(&self) -> Result<()> { + let Some(window) = self.topmost()? else { + return Ok(()); + }; + if let Ok(Ok(geometry)) = self.conn.get_geometry(window).map(|c| c.reply()) { + if geometry.x != 0 || geometry.y != 0 { + let at = ConfigureWindowAux::new().x(0).y(0); + let _ = self.conn.configure_window(window, &at); + } + } + self.conn + .set_input_focus(InputFocus::PARENT, window, x11rb::CURRENT_TIME) + .context("cannot set the input focus")?; + self.conn.flush()?; + Ok(()) + } + + /// Whether anything worth showing is on the display yet. The portal can take + /// half a minute to draw its first window, and a viewer that said nothing + /// would look broken for that whole time. + pub fn has_window(&self) -> bool { + matches!(self.topmost(), Ok(Some(_))) + } + + /// The topmost mapped child of the root that is big enough to be a real + /// window. Tooltips and the 1×1 helper windows GTK creates are skipped. + fn topmost(&self) -> Result> { + let tree = self + .conn + .query_tree(self.root) + .context("cannot ask the X server for the window tree")? + .reply() + .context("the X server refused the window tree")?; + // `children` is in bottom-to-top stacking order. + for &child in tree.children.iter().rev() { + let Ok(cookie) = self.conn.get_window_attributes(child) else { + continue; + }; + let Ok(attrs) = cookie.reply() else { continue }; + if attrs.map_state != MapState::VIEWABLE { + continue; + } + let Ok(cookie) = self.conn.get_geometry(child) else { + continue; + }; + let Ok(geometry) = cookie.reply() else { continue }; + if geometry.width >= 80 && geometry.height >= 40 { + return Ok(Some(child)); + } + } + Ok(None) + } + + /// Type one character. Returns `false` for a character the display's keyboard + /// map cannot produce, so the caller can say so instead of silently dropping + /// a letter of a password. + pub fn type_char(&self, c: char) -> Result { + let keysym = keysym_for_char(c); + let Some((code, shifted)) = self.keymap.lookup(keysym) else { + return Ok(false); + }; + self.tap(code, shifted)?; + Ok(true) + } + + /// Send a named key. + pub fn press(&self, key: Key) -> Result { + let Some((code, shifted)) = self.keymap.lookup(key.keysym()) else { + return Ok(false); + }; + // Shift+Tab is the one binding whose shift comes from the key, not from + // the keymap level. + self.tap(code, shifted || key == Key::BackTab)?; + Ok(true) + } + + fn tap(&self, code: u8, shifted: bool) -> Result<()> { + if shifted { + self.fake(KEY_PRESS, self.shift, 0, 0)?; + } + self.fake(KEY_PRESS, code, 0, 0)?; + self.fake(KEY_RELEASE, code, 0, 0)?; + if shifted { + self.fake(KEY_RELEASE, self.shift, 0, 0)?; + } + self.conn.flush()?; + Ok(()) + } + + /// Move the pointer to a pixel and press or release the left button there. + pub fn click(&self, x: i16, y: i16, down: bool) -> Result<()> { + self.fake(MOTION_NOTIFY, 0, x, y)?; + self.fake(if down { BUTTON_PRESS } else { BUTTON_RELEASE }, 1, x, y)?; + self.conn.flush()?; + Ok(()) + } + + fn fake(&self, type_: u8, detail: u8, x: i16, y: i16) -> Result<()> { + self.conn + .xtest_fake_input(type_, detail, 0, self.root, x, y, 0) + .context("cannot synthesise input (is the XTEST extension present?)")?; + Ok(()) + } +} + +/// The keysym for a character. For everything this viewer sends — printable +/// ASCII — the keysym IS the code point, which is why no table is needed. +fn keysym_for_char(c: char) -> u32 { + let code = c as u32; + if (0x20..0x7f).contains(&code) { + code + } else { + // Unicode keysyms are the code point plus this offset. Outside ASCII the + // keymap of a default Xvfb holds none of them, so `type_char` reports + // false and the caller says the character cannot be typed. + 0x0100_0000 + code + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_channel_scales_to_eight_bits() { + // 24-bit: the mask already spans 0..255. + let red = Channel::of(0x00ff_0000); + assert_eq!(red.value(0x00ab_0000), 0xab); + // 16-bit 5-6-5: full green must read as 255, not as 63. + let green = Channel::of(0x0000_07e0); + assert_eq!(green.value(0x0000_07e0), 255); + assert_eq!(green.value(0), 0); + } + + #[test] + fn an_ascii_char_is_its_own_keysym() { + assert_eq!(keysym_for_char('a'), 0x61); + assert_eq!(keysym_for_char('@'), 0x40); + assert_eq!(keysym_for_char('.'), 0x2e); + } + + #[test] + fn a_keymap_finds_shifted_and_unshifted_symbols() { + // Two keycodes, two levels each: 'a'/'A' and '1'/'!'. + let keymap = Keymap { + min: 8, + per_code: 2, + syms: vec![0x61, 0x41, 0x31, 0x21], + }; + assert_eq!(keymap.lookup(0x61), Some((8, false))); + assert_eq!(keymap.lookup(0x41), Some((8, true))); + assert_eq!(keymap.lookup(0x31), Some((9, false))); + assert_eq!(keymap.lookup(0x21), Some((9, true))); + assert_eq!(keymap.lookup(0xff0d), None); + } + + /// The whole X half, against a real server: capture returns the screen, and a + /// typed line reaches a window that is not ours. + /// + /// Ignored by default because it needs `Xvfb` and `xterm` on the machine — + /// run it with `cargo test -- --ignored --nocapture`. It is the only test that + /// can catch the two failures unit tests cannot: a pixel format read wrong + /// (the picture is there but the colours are swapped) and input that goes + /// nowhere because no window holds the focus. + #[test] + #[ignore = "needs Xvfb and xterm on this machine"] + fn typing_reaches_a_window_on_a_real_display() { + use std::path::Path; + use std::process::{Command, Stdio}; + + for tool in ["Xvfb", "xterm"] { + if Command::new("sh") + .arg("-c") + .arg(format!("command -v {tool}")) + .stdout(Stdio::null()) + .status() + .map(|s| !s.success()) + .unwrap_or(true) + { + eprintln!("skipped: {tool} is not installed"); + return; + } + } + + let proof = format!("/tmp/intune-container-xtest-{}", std::process::id()); + let _ = std::fs::remove_file(&proof); + + let xvfb = crate::xvfb::Xvfb::start(800, 600, None).expect("start Xvfb"); + let screen = XScreen::connect(&xvfb.display()).expect("connect"); + assert_eq!((screen.width, screen.height), (800, 600)); + + // A blank root is black; xterm's default background is white, so the + // capture must change once the window is up. That is the pixel-format + // check: read with the wrong masks and white does not come back white. + let blank = screen.capture().expect("capture the blank display"); + assert_eq!(blank.rgb.len(), 800 * 600 * 3); + + let mut xterm = Command::new("xterm") + .env("DISPLAY", xvfb.display()) + .args(["-geometry", "80x24+0+0", "-bg", "white", "-fg", "black"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn xterm"); + + let mut seen_white = false; + for _ in 0..100 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if screen.has_window() { + let frame = screen.capture().expect("capture with a window"); + // Somewhere in the top-left there must now be a white pixel. + seen_white = frame + .rgb + .chunks(3) + .any(|p| p[0] > 200 && p[1] > 200 && p[2] > 200); + if seen_white { + break; + } + } + } + assert!(seen_white, "the window never appeared, or the pixel format is wrong"); + + screen.place_and_focus().expect("focus"); + for c in format!("touch {proof}").chars() { + assert!(screen.type_char(c).expect("type"), "cannot type {c:?}"); + } + screen.press(Key::Return).expect("press Return"); + + let mut landed = false; + for _ in 0..50 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if Path::new(&proof).exists() { + landed = true; + break; + } + } + let _ = xterm.kill(); + let _ = xterm.wait(); + let _ = std::fs::remove_file(&proof); + assert!( + landed, + "the typed line never reached the window — XTEST input or the focus is wrong" + ); + } + + #[test] + fn a_third_level_symbol_is_not_offered() { + // AltGr symbols sit at level 2+. Reporting one would type the wrong + // character, because this viewer only ever holds Shift. + let keymap = Keymap { + min: 8, + per_code: 4, + syms: vec![0x61, 0x41, 0x00e6, 0x00c6], + }; + assert_eq!(keymap.lookup(0x00e6), None); + } +} diff --git a/src/xvfb.rs b/src/xvfb.rs new file mode 100644 index 0000000..4bfb94b --- /dev/null +++ b/src/xvfb.rs @@ -0,0 +1,151 @@ +//! A private, invisible X display for one sign-in. +//! +//! WHY IT IS OURS AND NOT THE USER'S. XTEST input goes wherever the display's +//! input focus is, so typing a password into a *shared* display would be a race +//! with whatever else is on it. This one is created for the sign-in, holds +//! nothing else, and is destroyed with the command — which also means a headless +//! server needs no compositor, no VNC server and no viewer. + +use std::path::Path; +use std::process::{Child, Command, Stdio}; + +use anyhow::{Context, Result}; +use tracing::debug; + +/// Where to start looking for a display number. Well clear of `:0`/`:1` (a real +/// session, or the VNC display a human might be using) and of `:99`, which the +/// container's own compliance agent runs on. +const FIRST_DISPLAY: u32 = 77; + +/// A running `Xvfb`, killed when this value is dropped. +pub struct Xvfb { + number: u32, + child: Child, +} + +impl Xvfb { + /// Start an invisible display of `width × height`. `number` picks the display + /// number; `None` takes the first free one. + pub fn start(width: u32, height: u32, number: Option) -> Result { + let number = match number { + Some(n) => n, + None => first_free_display(|n| display_taken(n)) + .context("no free X display number between :77 and :99")?, + }; + // Xvfb's own diagnostics go to a file rather than to /dev/null: "Server is + // already active for display 77" is the difference between a bug in this + // code and a display number that is genuinely taken. + let log_path = format!("/tmp/intune-container-xvfb-{number}.log"); + let log = std::fs::File::create(&log_path) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()); + + let child = Command::new("Xvfb") + .arg(format!(":{number}")) + .args(["-screen", "0", &format!("{width}x{height}x24")]) + // No TCP, ever: the sign-in page is on this display, and the machine + // may be on a network where a listening X server is reachable. + .arg("-nolisten") + .arg("tcp") + // Survive the last client disconnecting, so the display outlives one + // portal window and the viewer keeps working across a relaunch. + .arg("-noreset") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .context( + "cannot start Xvfb — install it (Debian/Ubuntu: sudo apt install xvfb) and retry", + )?; + + let mut xvfb = Self { number, child }; + xvfb.wait_until_ready(&log_path)?; + debug!(display = %xvfb.display(), width, height, "private X display up"); + Ok(xvfb) + } + + /// The `DISPLAY` value for this server, for example `":77"`. + pub fn display(&self) -> String { + format!(":{}", self.number) + } + + /// Block until the display ANSWERS, or fail with the reason it did not. + /// + /// It really has to connect: the socket file appears at `bind` time, before + /// the server `listen`s, so a wait that only stats the path hands back a + /// display whose first connection is refused. That cost an hour once — the + /// socket was there, the server was not, and the error read like a bug in the + /// X code rather than a race in this function. + /// + /// A dead child is reported ahead of the timeout, with what Xvfb said, because + /// "already active for display 77" needs a different fix from "not installed". + fn wait_until_ready(&mut self, log_path: &str) -> Result<()> { + let display = self.display(); + for _ in 0..100 { + if let Ok(Some(status)) = self.child.try_wait() { + let said = std::fs::read_to_string(log_path).unwrap_or_default(); + let said = said.trim(); + anyhow::bail!( + "Xvfb exited immediately ({status}){}{}", + if said.is_empty() { "" } else { ": " }, + said.lines().next().unwrap_or("") + ); + } + if x11rb::connect(Some(&display)).is_ok() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + anyhow::bail!("Xvfb did not answer on {display} within five seconds (see {log_path})") + } +} + +impl Drop for Xvfb { + /// Ask the server to stop before killing it, so it removes its own socket and + /// lock file. A SIGKILL leaves both behind, and the next run then finds a + /// stale `/tmp/.X-lock` — which is how a display number that nothing is + /// using stops being usable. + fn drop(&mut self) { + let pid = nix::unistd::Pid::from_raw(self.child.id() as i32); + let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM); + for _ in 0..40 { + match self.child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Is a display number already in use? True when either the X socket or the lock +/// file exists — the lock is what a display leaves behind when its server dies +/// badly, and starting a second server on it fails. +fn display_taken(number: u32) -> bool { + Path::new(&format!("/tmp/.X11-unix/X{number}")).exists() + || Path::new(&format!("/tmp/.X{number}-lock")).exists() +} + +/// The first free display number in `77..=99`, or `None`. +fn first_free_display(taken: impl Fn(u32) -> bool) -> Option { + (FIRST_DISPLAY..=99).find(|n| !taken(*n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_free_number_is_taken() { + assert_eq!(first_free_display(|_| false), Some(77)); + assert_eq!(first_free_display(|n| n < 79), Some(79)); + } + + #[test] + fn a_full_range_is_reported_rather_than_guessed() { + // Better a clear failure than a second server fighting for :77. + assert_eq!(first_free_display(|_| true), None); + } +} From 99a477a133a50a932ebc43f3794c37a8359330d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20WALLEZ?= Date: Tue, 4 Aug 2026 20:25:30 +0000 Subject: [PATCH 2/5] feat(login): drive the sign-in from the command, and show the window legibly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `login` drew a window and left the reader to type into it, which is not what a command is for — and what it drew was unreadable: 1280x800 of mostly black desktop squeezed into a terminal turns 13-pixel text into two rows of colour. Now the command does the work. It presses Sign in, and fills the address and the password when asked to (--fill); a device that has signed in before has its account remembered, so Entra goes straight to the Authenticator prompt and there is nothing to fill — measured on the tenant, and the reason credentials are optional rather than required. There is no DOM behind that window (the broker renders it in its own WebKit view), so every step is timed on the PICTURE: act, wait for it to move, wait for it to settle. Three false positives had to die first, all the same mistake — treating any pixel change as proof of a click: * Enter alone does nothing: GTK gives that window no default button; * Tab draws a focus ring, which moves ~3% of the pixels and read exactly like a press. The threshold for 'a new page' is now 15%; * place_and_focus MOVES the window, so measuring the button before placing it clicked where the window used to be and then read its own move as success. The button is clicked at a measured position (50%, 67% of the window) with the keyboard as the fallback, not the other way round: a click is the only attempt whose result can be verified. What is drawn is now the window rather than the desktop (content_bounds), and the viewer opens at ACTUAL SIZE rather than fitted — cropping is worth about a quarter, measured, because the dialog is tall while a terminal has some hundred half-rows. Fitting is F4. Verified against the real tenant end to end: the portal opens, the button is clicked, and Entra reaches 'Approve sign in' with a number-matching code on screen and a request on the phone. --- src/login.rs | 254 ++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 42 +++++++- src/termview.rs | 252 +++++++++++++++++++++++++++++++++++++++++++++++ src/xscreen.rs | 17 ++++ 4 files changed, 553 insertions(+), 12 deletions(-) diff --git a/src/login.rs b/src/login.rs index f0f9c6f..1b6f633 100644 --- a/src/login.rs +++ b/src/login.rs @@ -27,19 +27,28 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use nix::sys::termios::{self, SetArg, Termios}; -use crate::termview::{self, Action, Cell, Frame, Viewport}; +use crate::termview::{self, Action, Cell, Frame, Key, Viewport}; use crate::xscreen::XScreen; use crate::xvfb::Xvfb; use crate::{display, ops}; /// How the reader asked for the session to look. -#[derive(Debug, Clone, Copy)] pub struct Options { /// The size of the private display, in pixels. pub width: u32, pub height: u32, /// Force a display number instead of taking the first free one. pub display: Option, + /// Drive the window: press Sign in, and fill the form when `credentials` are + /// given. False hands the window over untouched. + pub automatic: bool, + /// The address and password to fill in. + /// + /// `None` is the common case and not a lesser one: a device that has signed in + /// before has its account remembered, so Entra goes straight from Sign in to the + /// Authenticator prompt and there is no field to fill. Measured on this tenant — + /// typing an address at that point reaches nothing at all. + pub credentials: Option, } impl Default for Options { @@ -51,6 +60,8 @@ impl Default for Options { width: 1280, height: 800, display: None, + automatic: true, + credentials: None, } } } @@ -117,9 +128,15 @@ pub fn run(options: Options) -> Result<()> { has_abstract_x11: false, }; - print_keys(&display_name); ops::portal_start(&display_info).context("could not start the Intune portal")?; + // Fill the form before the terminal is taken over, so every step is a plain + // line the reader can keep. + if options.automatic { + automate(&screen, options.credentials.as_ref())?; + } + print_keys(&display_name); + let outcome = { let tty = RawTty::enter()?; view(&screen, &tty) @@ -146,6 +163,207 @@ pub fn run(options: Options) -> Result<()> { Ok(()) } +/// What the command types into the sign-in page. Deliberately not `Debug`: a +/// derived one would put the password in any log line that printed the options. +pub struct Credentials { + pub email: String, + pub password: String, +} + +/// Ask for the address and the password on the terminal. The password is read +/// without echo, and neither value is ever logged or written anywhere. +pub fn prompt_credentials(email: Option) -> Result { + let email = match email { + Some(email) => email, + None => { + eprint!("Work address: "); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("cannot read the address")?; + line.trim().to_string() + } + }; + if email.is_empty() { + anyhow::bail!("no address given"); + } + let password = + rpassword::prompt_password("Password (not shown): ").context("cannot read the password")?; + if password.is_empty() { + anyhow::bail!("no password given"); + } + Ok(Credentials { email, password }) +} + +/// How long a step waits for the page to answer. Generous: the portal's first +/// window can take half a minute, and Entra's own pages are a network round trip +/// away. +const STEP_TIMEOUT: Duration = Duration::from_secs(90); + +/// A screen is "settled" once it has not moved by more than this fraction for +/// [`STILL_FOR`]. Small enough to ignore a text cursor blinking, large enough that +/// a spinner does not hold the flow open for ever. +const STILL_ENOUGH: f32 = 0.01; +const STILL_FOR: Duration = Duration::from_millis(1200); + +/// A change this large means the page moved on — a new dialog, a new step. +/// A change this large is a new PAGE, not a focus ring or a caret. Measured: the +/// focus ring Tab draws on the portal moves about 3% of the pixels, and treating +/// that as a press is what made the first version report a sign-in that never +/// happened. +const NEW_PAGE: f32 = 0.15; + +/// Drive the sign-in: press the button, fill the address, fill the password. +/// +/// WHAT IT CANNOT KNOW. There is no DOM behind this window — the broker renders it +/// in its own WebKit view — so the flow is timed on the PICTURE: type, wait for it +/// to move, wait for it to settle, type again. That is why every step says what it +/// did, and why the viewer opens afterwards instead of the command claiming +/// success: if a page ever appears in a different order, the reader sees the real +/// screen and can finish it by hand. +/// +/// The one thing it never guesses is the multi-factor challenge. Approving that is +/// the reader's, on their phone. +fn automate(screen: &XScreen, credentials: Option<&Credentials>) -> Result<()> { + eprintln!("· waiting for the portal window"); + wait_until_still(screen, STEP_TIMEOUT)?; + // Place it BEFORE the baseline is taken. place_and_focus moves the window to + // the origin, which is a huge pixel change and a wholesale change of + // coordinates: measuring the button first meant clicking where the window used + // to be, and then reading its own move as the page having opened. + screen.place_and_focus()?; + let settled = wait_until_still(screen, STEP_TIMEOUT)?; + + eprintln!("· pressing Sign in"); + let settled = match press_sign_in(screen, &settled)? { + Some(frame) => frame, + None => { + eprintln!(" the window did not react — finish it in the viewer below"); + return Ok(()); + } + }; + let _ = wait_until_still(screen, STEP_TIMEOUT)?; + let _ = settled; + + let Some(credentials) = credentials else { + eprintln!("· signed in as the remembered account — nothing to type"); + eprintln!(" the screen below is what the sign-in shows now"); + return Ok(()); + }; + + eprintln!("· typing the address"); + screen.place_and_focus()?; + report_refused(screen.type_text(&credentials.email)?); + screen.press(Key::Return)?; + wait_until_still(screen, STEP_TIMEOUT)?; + + eprintln!("· typing the password"); + screen.place_and_focus()?; + report_refused(screen.type_text(&credentials.password)?); + screen.press(Key::Return)?; + wait_until_still(screen, STEP_TIMEOUT)?; + + eprintln!("· done typing — the screen below is what the sign-in shows now"); + Ok(()) +} + +/// Where the portal's Sign in button sits inside its window, as a fraction of the +/// window. Measured on the real portal: the button is centred across a 478-pixel +/// dialog and 420 pixels down its 628. +const SIGN_IN_AT: (f32, f32) = (0.50, 0.67); + +/// Activate the portal's Sign in button, and return the screen it led to. +/// +/// The click comes FIRST, because it is the only attempt that can be verified: a +/// real activation replaces the dialog with the sign-in page, which is a large +/// change, while Tab merely draws a focus ring — and that ring cost an hour, because +/// it changed enough pixels to look exactly like a press. So the threshold here is +/// deliberately high, and the keyboard is the fallback rather than the first try. +/// +/// `None` means nothing moved the screen, and the caller hands the window to the +/// reader rather than pretending it signed in. +fn press_sign_in(screen: &XScreen, before: &Frame) -> Result> { + let bounds = termview::content_bounds(before); + let x = (bounds.x as f32 + bounds.width as f32 * SIGN_IN_AT.0).round() as i16; + let y = (bounds.y as f32 + bounds.height as f32 * SIGN_IN_AT.1).round() as i16; + screen.click(x, y, true)?; + std::thread::sleep(Duration::from_millis(80)); + screen.click(x, y, false)?; + if let Some(frame) = wait_for_page(screen, before, Duration::from_secs(25))? { + eprintln!(" clicked the button at {x}, {y}"); + return Ok(Some(frame)); + } + + // Keyboard fallback, for a portal whose button has moved: Tab walks the focus + // ring, and Enter or space presses whatever holds it. + for step in 1..=6 { + screen.press(Key::Tab)?; + std::thread::sleep(Duration::from_millis(120)); + screen.press(Key::Return)?; + screen.press(Key::Space)?; + if let Some(frame) = wait_for_page(screen, before, Duration::from_secs(4))? { + eprintln!(" activated with the keyboard ({step} tabs)"); + return Ok(Some(frame)); + } + } + Ok(None) +} + +/// Wait for a change big enough to BE a new page rather than a focus ring. +fn wait_for_page(screen: &XScreen, base: &Frame, timeout: Duration) -> Result> { + let start = Instant::now(); + loop { + std::thread::sleep(Duration::from_millis(250)); + let current = screen.capture()?; + if termview::difference(base, ¤t) > NEW_PAGE { + return Ok(Some(current)); + } + if start.elapsed() >= timeout { + return Ok(None); + } + if INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("interrupted"); + } + } +} + +/// Say which characters this keyboard could not type, rather than letting a +/// half-typed password fail as a wrong one. +fn report_refused(refused: Vec) { + if !refused.is_empty() { + eprintln!( + " warning: this display's keyboard cannot type {refused:?} — that field is incomplete" + ); + } +} + +/// Wait until the picture stops moving, and return it. +fn wait_until_still(screen: &XScreen, timeout: Duration) -> Result { + let start = Instant::now(); + let mut previous = screen.capture()?; + let mut still_since = Instant::now(); + loop { + std::thread::sleep(Duration::from_millis(200)); + let current = screen.capture()?; + if termview::difference(&previous, ¤t) > STILL_ENOUGH { + still_since = Instant::now(); + } + previous = current; + if still_since.elapsed() >= STILL_FOR { + return Ok(previous); + } + if start.elapsed() >= timeout { + // Not an error: a page that never settles is still a page the reader + // can look at and use. + return Ok(previous); + } + if INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("interrupted"); + } + } +} + /// Set by [`catch_hangups`] when the session is cut from outside. The viewer polls /// it so the ordinary teardown runs — the portal is closed, the container goes back /// to headless and the X server is stopped. @@ -187,7 +405,16 @@ struct Session { /// Draw the display and forward input until the reader quits or the window goes. fn view(screen: &XScreen, tty: &RawTty) -> Result { let (mut cols, mut rows) = tty.size(); - let mut viewport = Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)); + // Fit the WINDOW, not the desktop it sits on. The dialog is a third of the + // screen, so fitting the screen shrank it past reading. + let mut viewport = match screen.capture() { + Ok(frame) => Viewport::at_actual_size( + termview::content_bounds(&frame), + cols, + picture_rows(rows), + ), + Err(_) => Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)), + }; let mut previous: Option> = None; let mut input = Vec::new(); let mut help = false; @@ -230,7 +457,18 @@ fn view(screen: &XScreen, tty: &RawTty) -> Result { } Action::Redraw => previous = None, Action::Fit => { - viewport = Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)) + // Fit the window again, wherever it has moved to — the sign-in + // opens a second, differently sized one partway through. + viewport = match screen.capture() { + Ok(frame) => Viewport::fitted_to( + termview::content_bounds(&frame), + cols, + picture_rows(rows), + ), + Err(_) => { + Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)) + } + } } Action::Actual => { viewport.scale = 1.0; @@ -415,15 +653,13 @@ fn help_overlay(cols: u16) -> String { /// portal takes up to half a minute to draw its first window. fn print_keys(display: &str) { eprintln!(); - eprintln!("The sign-in window will be drawn in this terminal (display {display})."); + eprintln!("The window is now drawn below (display {display}). If a two-digit number is"); + eprintln!("on it, enter that number in Authenticator; press F2 to make it bigger."); eprintln!(); for (keys, what) in termview::HELP { eprintln!(" {keys:<20} {what}"); } eprintln!(); - eprintln!("Type your address, press Enter, type your password, press Enter. When a"); - eprintln!("two-digit number appears, zoom in with F2 and enter it in Authenticator."); - eprintln!(); } /// The terminal, in raw mode with the alternate screen and mouse reporting on. diff --git a/src/main.rs b/src/main.rs index 55193eb..86f2c6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,20 @@ enum Command { }, /// Sign in to Entra ID from this terminal (no screen, no VNC needed) Login { + /// Ask for an address and password to fill in. Not needed when this device + /// has signed in before: Entra remembers the account and goes straight to + /// the Authenticator prompt. + #[arg(long)] + fill: bool, + + /// Work address to fill in (implies --fill; asked for when omitted) + #[arg(long, value_name = "ADDRESS")] + email: Option, + + /// Touch nothing — just show the window and drive it by hand + #[arg(long)] + manual: bool, + /// Size of the private display the sign-in is drawn on #[arg(long, default_value = "1280x800", value_name = "WxH")] geometry: String, @@ -232,7 +246,13 @@ fn main() -> Result<()> { Command::Enroll { image } => cmd_enroll(image)?, Command::Start => cmd_start()?, Command::Edge { verbose, args } => ops::edge(verbose, &args)?, - Command::Login { geometry, display } => cmd_login(&geometry, display)?, + Command::Login { + fill, + email, + manual, + geometry, + display, + } => cmd_login(fill, email, manual, &geometry, display)?, Command::Stop => ops::stop()?, Command::Status => cmd_status()?, Command::Autostart { action } => match action { @@ -418,13 +438,29 @@ fn cmd_start() -> Result<()> { Ok(()) } -/// Sign in interactively with the window drawn in this terminal. -fn cmd_login(geometry: &str, display: Option) -> Result<()> { +/// Sign in from this terminal: the command fills the form, the reader approves the +/// multi-factor prompt on their phone. +fn cmd_login( + fill: bool, + email: Option, + manual: bool, + geometry: &str, + display: Option, +) -> Result<()> { let (width, height) = login::parse_geometry(geometry)?; + // Asked for BEFORE anything starts: a password prompt is the one thing that + // must not appear underneath a minute of container startup logs. + let credentials = if !manual && (fill || email.is_some()) { + Some(login::prompt_credentials(email)?) + } else { + None + }; login::run(login::Options { width, height, display, + automatic: !manual, + credentials, }) } diff --git a/src/termview.rs b/src/termview.rs index 6ec7060..95fac87 100644 --- a/src/termview.rs +++ b/src/termview.rs @@ -82,6 +82,94 @@ impl Frame { } } +/// A rectangle of the frame, in pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bounds { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +/// A pixel this dark in every channel is the empty desktop, not content. The +/// portal's own background is near-white and every dialog has a border, so the +/// threshold only has to clear black. +const DESKTOP: u8 = 24; + +/// The smallest rectangle holding everything that is not empty desktop. +/// +/// WHY IT MATTERS. The sign-in dialog is about 480×630 on a 1280×800 display, so +/// two thirds of the screen is black and fitting the whole screen spends most of +/// the terminal's cells on nothing. Cropping to the content is worth about a +/// quarter — measured, not assumed, and less than it sounds because the dialog is +/// TALL while a terminal has some hundred half-rows. That measurement is why +/// [`Viewport::at_actual_size`] and not a fit is what the viewer opens on. +/// +/// Returns the whole frame when it is empty, so a caller never has to special-case +/// a blank display. +pub fn content_bounds(frame: &Frame) -> Bounds { + let (mut x0, mut y0) = (frame.width, frame.height); + let (mut x1, mut y1) = (0u32, 0u32); + // Every other pixel: the dialog is hundreds of pixels wide, so a half- + // resolution scan finds the same box for a quarter of the work. + for y in (0..frame.height).step_by(2) { + for x in (0..frame.width).step_by(2) { + let p = frame.pixel(x, y); + if p[0] > DESKTOP || p[1] > DESKTOP || p[2] > DESKTOP { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + if x1 <= x0 || y1 <= y0 { + return Bounds { + x: 0, + y: 0, + width: frame.width, + height: frame.height, + }; + } + Bounds { + x: x0, + y: y0, + // +2 for the step, clamped: the scan can miss the last odd row/column. + width: (x1 - x0 + 2).min(frame.width - x0), + height: (y1 - y0 + 2).min(frame.height - y0), + } +} + +/// How much two frames differ, as a fraction of the pixels compared. +/// +/// This is how the automation knows a page changed without a DOM to ask: it types, +/// then waits for the picture to move and then to settle. Sampled every fourth +/// pixel in both directions, which is 16 times less work and still catches a +/// dialog appearing. +pub fn difference(a: &Frame, b: &Frame) -> f32 { + if a.width != b.width || a.height != b.height { + return 1.0; + } + let (mut differing, mut total) = (0u32, 0u32); + for y in (0..a.height).step_by(4) { + for x in (0..a.width).step_by(4) { + let p = a.pixel(x, y); + let q = b.pixel(x, y); + let delta = (p[0] as i32 - q[0] as i32).abs() + + (p[1] as i32 - q[1] as i32).abs() + + (p[2] as i32 - q[2] as i32).abs(); + if delta > 24 { + differing += 1; + } + total += 1; + } + } + if total == 0 { + return 0.0; + } + differing as f32 / total as f32 +} + /// Which part of the frame the terminal shows, and how magnified. /// /// `scale` is source pixels per cell across (and per half-cell down), so a @@ -105,6 +193,39 @@ pub fn fit_scale(width: u32, height: u32, cols: u16, rows: u16) -> f32 { } impl Viewport { + /// Fit `bounds` — the window rather than the desktop around it — and keep the + /// rest of the frame reachable by panning. + pub fn fitted_to(bounds: Bounds, cols: u16, rows: u16) -> Self { + let scale = fit_scale(bounds.width, bounds.height, cols, rows); + let (vw, vh) = (cols as f32 * scale, rows as f32 * 2.0 * scale); + Self { + scale, + // Centre the content in the view, which letterboxes rather than + // cropping when the shapes do not match. + ox: bounds.x as f32 - (vw - bounds.width as f32) / 2.0, + oy: bounds.y as f32 - (vh - bounds.height as f32) / 2.0, + } + } + + /// One pixel per half cell, anchored at the TOP of `bounds` and centred across + /// it. + /// + /// This is what the viewer opens on, and the reason is arithmetic: a terminal + /// has about 100 half-rows, the sign-in dialog is some 630 pixels tall, so + /// fitting it costs a factor of six and 13-pixel text becomes two rows of + /// colour. At actual size the same text is legible and the multi-factor number + /// — which is what a reader is actually here to read, and which Entra puts near + /// the top — is unmistakable. `F4` fits the whole window when a map is what is + /// wanted. + pub fn at_actual_size(bounds: Bounds, cols: u16, _rows: u16) -> Self { + let vw = cols as f32; + Self { + scale: 1.0, + ox: bounds.x as f32 + (bounds.width as f32 - vw) / 2.0, + oy: bounds.y as f32, + } + } + /// The whole screen, centred. pub fn fitted(frame_w: u32, frame_h: u32, cols: u16, rows: u16) -> Self { let scale = fit_scale(frame_w, frame_h, cols, rows); @@ -252,6 +373,7 @@ pub fn paint(prev: Option<&[Cell]>, next: &[Cell], cols: u16, rows: u16) -> Stri #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { Return, + Space, Tab, BackTab, BackSpace, @@ -272,6 +394,8 @@ impl Key { pub fn keysym(self) -> u32 { match self { Key::Return => 0xff0d, + // Space activates a focused GTK button, which Enter does not. + Key::Space => 0x0020, Key::Tab | Key::BackTab => 0xff09, Key::BackSpace => 0xff08, Key::Delete => 0xffff, @@ -532,6 +656,134 @@ pub const HELP: &[(&str, &str)] = &[ mod tests { use super::*; + /// A frame with a white rectangle on a black desktop, like the portal's dialog. + fn frame_with_window(w: u32, h: u32, win: Bounds) -> Frame { + let mut frame = Frame::flat(w, h, [0, 0, 0]); + for y in win.y..win.y + win.height { + for x in win.x..win.x + win.width { + let i = ((y * w + x) * 3) as usize; + frame.rgb[i] = 246; + frame.rgb[i + 1] = 245; + frame.rgb[i + 2] = 244; + } + } + frame + } + + #[test] + fn the_content_box_is_the_window_not_the_desktop() { + let win = Bounds { + x: 100, + y: 60, + width: 200, + height: 300, + }; + let found = content_bounds(&frame_with_window(1280, 800, win)); + // Within the scan's two-pixel step. + assert!(found.x <= win.x && win.x - found.x <= 2, "{found:?}"); + assert!(found.y <= win.y && win.y - found.y <= 2, "{found:?}"); + assert!(found.width >= win.width && found.width <= win.width + 4, "{found:?}"); + assert!(found.height >= win.height && found.height <= win.height + 4, "{found:?}"); + } + + #[test] + fn an_empty_display_reports_the_whole_frame() { + // Otherwise the viewer would have to special-case a display with no window + // yet — which is every display for the first half minute. + let found = content_bounds(&Frame::flat(800, 600, [0, 0, 0])); + assert_eq!( + found, + Bounds { + x: 0, + y: 0, + width: 800, + height: 600 + } + ); + } + + /// The real dialog, measured on the tenant: 480×630 at 1280×800. + const DIALOG: Bounds = Bounds { + x: 400, + y: 100, + width: 480, + height: 630, + }; + + #[test] + fn fitting_the_window_beats_fitting_the_screen_but_not_by_much() { + let whole = Viewport::fitted(1280, 800, 200, 50); + let cropped = Viewport::fitted_to(DIALOG, 200, 50); + assert!( + cropped.scale < whole.scale, + "cropping lost ground: {} vs {}", + cropped.scale, + whole.scale + ); + // And it is only about a quarter better, because the dialog is TALL and a + // terminal has ~100 half-rows: the height binds, not the black desktop. This + // is why the viewer opens at actual size rather than fitted — a `fit` that + // reads as unusable would look like a broken renderer. + assert!( + cropped.scale > whole.scale / 2.0, + "if cropping ever gains this much, revisit the default view" + ); + // The window is inside the view. + let (vw, vh) = cropped.covered(200, 50); + assert!(cropped.ox <= DIALOG.x as f32 && cropped.oy <= DIALOG.y as f32); + assert!(cropped.ox + vw >= (DIALOG.x + DIALOG.width) as f32); + assert!(cropped.oy + vh >= (DIALOG.y + DIALOG.height) as f32); + } + + #[test] + fn actual_size_shows_the_top_of_the_window_centred() { + let vp = Viewport::at_actual_size(DIALOG, 200, 50); + assert_eq!(vp.scale, 1.0); + // The top edge, so the challenge text is on screen without panning. + assert_eq!(vp.oy, DIALOG.y as f32); + // Centred across the dialog: 480 wide, 200 columns → 140 px of margin each + // side. + assert_eq!(vp.ox, DIALOG.x as f32 + 140.0); + // And a terminal WIDER than the dialog still centres it rather than pushing + // it off to one side. + let wide = Viewport::at_actual_size(DIALOG, 600, 50); + assert_eq!(wide.ox, DIALOG.x as f32 - 60.0); + } + + #[test] + fn actual_size_is_legible_where_fitting_is_not() { + // The measurement behind the default: 13-pixel body text and a ~40-pixel + // challenge number, in half-rows of terminal. + let fitted = Viewport::fitted_to(DIALOG, 200, 50); + let body_rows_fitted = 13.0 / fitted.scale; + let number_rows_fitted = 40.0 / fitted.scale; + assert!(body_rows_fitted < 3.0, "body text was legible when fitted?"); + assert!(number_rows_fitted < 8.0); + let actual = Viewport::at_actual_size(DIALOG, 200, 50); + assert!(13.0 / actual.scale >= 13.0); + assert!(40.0 / actual.scale >= 40.0); + } + + #[test] + fn difference_sees_a_dialog_appear_and_ignores_a_still_screen() { + let blank = Frame::flat(400, 300, [0, 0, 0]); + assert_eq!(difference(&blank, &blank), 0.0); + let with_window = frame_with_window( + 400, + 300, + Bounds { + x: 0, + y: 0, + width: 200, + height: 300, + }, + ); + // Half the screen changed; anything above a few percent is a real change. + assert!(difference(&blank, &with_window) > 0.4); + // A different size is treated as a total change rather than compared. + assert_eq!(difference(&blank, &Frame::flat(10, 10, [0, 0, 0])), 1.0); + } + #[test] fn a_cell_averages_the_pixels_it_covers() { // Two columns: black and white. One cell over both is mid grey — under diff --git a/src/xscreen.rs b/src/xscreen.rs index 23db9c3..91be71d 100644 --- a/src/xscreen.rs +++ b/src/xscreen.rs @@ -301,6 +301,23 @@ impl XScreen { Ok(true) } + /// Type a whole string, one key at a time. + /// + /// Returns the characters this display's keyboard could not produce, so the + /// caller can say which ones rather than silently signing in with half a + /// password. Paced deliberately: a web input with JavaScript handlers on every + /// keystroke drops characters typed at full XTEST speed. + pub fn type_text(&self, text: &str) -> Result> { + let mut refused = Vec::new(); + for c in text.chars() { + if !self.type_char(c)? { + refused.push(c); + } + std::thread::sleep(std::time::Duration::from_millis(12)); + } + Ok(refused) + } + /// Send a named key. pub fn press(&self, key: Key) -> Result { let Some((code, shifted)) = self.keymap.lookup(key.keysym()) else { From 2b262175b1e270b560ed0e1e9c2aff10be79c120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20WALLEZ?= Date: Tue, 4 Aug 2026 20:33:26 +0000 Subject: [PATCH 3/5] fix(login): never leave the broker pointing at the private display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal sign-in broke the whole account minutes after it ended, and the app blamed the keyring. The portal is launched through a login shell, and the container's session profile publishes whatever DISPLAY it is given into the D-Bus activation environment. For `edge` that display is the user's own and outlives the command; `login` creates an EPHEMERAL one, so the identity broker — a GTK program, activated on demand — was left with `DISPLAY=:77` after the Xvfb was gone. Every token call then answered NoReply, which is the same signature as a locked keyring: the banner said the keyring, `doctor` said all six checks pass, and the automatic repair restarted the container three times before hitting its own rate limit. The real cause was one line in the container's own journal: `cannot open display: :77`. portal_start now puts the broker back on the container's :99 before it returns, and starts that display rather than assuming it — a container booted with a display forwarded has none. The portal keeps its own DISPLAY, which is the only process that needs ours. Verified live: a login run killed mid-session leaves getAccounts working and a Graph token mintable, where before it left the account signed out. --- src/ops.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++++- src/provision.rs | 30 +++++++++++++++++++---------- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/ops.rs b/src/ops.rs index 52c8fd8..1d0ae99 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -202,7 +202,22 @@ pub fn portal_start(display: &display::DisplayInfo) -> Result<()> { backend::wait_until_portal_ready(&config); info!("Starting the Intune portal — its window can take up to ~30s the first time."); - backend::exec(&config, "intune-portal", None, display) + backend::exec(&config, "intune-portal", None, display)?; + + // Take the broker off our display and put it back on the container's own. + // + // The portal is launched through a login shell, and the session profile + // publishes whatever DISPLAY it is given into the D-Bus activation + // environment — so without this the BROKER inherits the private display this + // command created, and dies with `cannot open display` the moment the command + // ends. That is not a small bug: every token call then fails, the app reports a + // broken sign-in, and the automatic repair restarts the container against a + // cause a restart cannot fix. + // + // The portal keeps its own DISPLAY (it was given one in its environment); only + // what the broker inherits is changed back. + backend::probe(&config, crate::provision::BROKER_DISPLAY_SCRIPT); + Ok(()) } /// Whether the portal window is still open. False also when the container is not @@ -693,3 +708,36 @@ fn remove_browser_sso_integration(home: &str) { } let _ = std::fs::remove_dir_all(format!("{home}/.local/lib/intune-container")); } + +#[cfg(test)] +mod tests { + /// The one thing a terminal sign-in must never leave behind: the broker + /// pointing at the private display the command created. + /// + /// It is checked on the SCRIPT rather than on a live container, because the + /// failure is silent — the sign-in works, the command exits, and the account + /// breaks minutes later with an error that names a keyring. + #[test] + fn the_portal_puts_the_broker_back_on_the_container_display() { + let script = crate::provision::BROKER_DISPLAY_SCRIPT; + assert!(script.contains("DISPLAY=:99"), "the container's own display"); + assert!( + script.contains("dbus-update-activation-environment"), + "publishing it is what the broker inherits" + ); + // And it starts that display rather than assuming it: a container booted + // with a host display forwarded has no :99 running. + assert!(script.contains("Xvfb :99")); + + let source = include_str!("ops.rs"); + let start = source + .split("pub fn portal_start") + .nth(1) + .expect("portal_start exists"); + let body = start.split("\n}\n").next().unwrap_or(start); + assert!( + body.contains("BROKER_DISPLAY_SCRIPT"), + "portal_start must restore the broker's display before it returns" + ); + } +} diff --git a/src/provision.rs b/src/provision.rs index 1ba12bb..d6b7369 100644 --- a/src/provision.rs +++ b/src/provision.rs @@ -133,22 +133,32 @@ fn seed_keyring_dir(rootfs: &Path, user: &ContainerUser) -> Result<()> { /// an unlocked gnome-keyring with a created `login` collection, the Intune /// **compliance agent timer** (without it the device never reports compliant), /// and a broker restart so it re-reads the keyring. Mirrors the nspawn session. -pub fn runtime_setup_script(user: &ContainerUser, headless: bool) -> String { - // Headless (background SSO): the identity broker is a GTK app that exits - // without a display, so start a private Xvfb and publish DISPLAY into the - // user D-Bus activation environment (where the on-demand broker inherits it). - let display_block = if headless { - r#"if command -v Xvfb >/dev/null 2>&1; then +/// Start the container's own private display and publish it to the D-Bus +/// activation environment, so the on-demand identity broker inherits a display +/// that exists. +/// +/// The broker is a GTK program: with no reachable `DISPLAY` it exits at activation +/// and every token call answers `NoReply`, which reads exactly like a locked +/// keyring. It has to be a display the CONTAINER owns, and that is the whole point +/// of `:99` — a forwarded host display works while the app using it is up, and +/// leaves the broker pointing at nothing the moment it goes. That happened: a +/// `login` session published its private Xvfb here through the login-shell profile, +/// was killed, and the broker then failed with `cannot open display: :77` until +/// somebody read the container's own journal. +pub const BROKER_DISPLAY_SCRIPT: &str = r#"if command -v Xvfb >/dev/null 2>&1; then pgrep -x Xvfb >/dev/null 2>&1 || setsid Xvfb :99 -screen 0 640x480x16 -nolisten tcp >/tmp/intune-xvfb.log 2>&1 & for _ in $(seq 1 20); do [ -S /tmp/.X11-unix/X99 ] && break; sleep 0.3; done export DISPLAY=:99 GDK_BACKEND=x11 systemctl --user import-environment DISPLAY GDK_BACKEND >/dev/null 2>&1 || true dbus-update-activation-environment --systemd DISPLAY GDK_BACKEND >/dev/null 2>&1 || true fi -"# - } else { - "" - }; +"#; + +pub fn runtime_setup_script(user: &ContainerUser, headless: bool) -> String { + // Headless (background SSO): the identity broker is a GTK app that exits + // without a display, so start a private Xvfb and publish DISPLAY into the + // user D-Bus activation environment (where the on-demand broker inherits it). + let display_block = if headless { BROKER_DISPLAY_SCRIPT } else { "" }; format!( r#"# Wait for the system to finish booting (logind + system bus). for _ in $(seq 1 90); do From 365aaa7af3f4bbb495c0cb26a3db7efd6235ed29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20WALLEZ?= Date: Wed, 12 Aug 2026 01:27:15 +0000 Subject: [PATCH 4/5] style(login): make the terminal sign-in pass fmt and current clippy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three files the terminal sign-in touched were committed unformatted, and stable clippy has since grown two lints that fire on them. CI runs `cargo fmt --all --check` and `cargo clippy --all-targets -- -D warnings`, so the branch fails both gates before any new code is added. Nothing here changes behaviour: * rustfmt over ops.rs, termview.rs and xscreen.rs (assertions and one tuple that were hand-wrapped past the line the formatter picks); * `manual_div_ceil` — the bits-per-pixel to bytes rounding is `div_ceil(8)`; and * `redundant_closure` — `first_free_display` takes the function, not a closure around it. --- src/ops.rs | 5 ++++- src/termview.rs | 31 ++++++++++++++++++++++--------- src/xscreen.rs | 11 ++++++++--- src/xvfb.rs | 2 +- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/ops.rs b/src/ops.rs index 1d0ae99..7dbe224 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -720,7 +720,10 @@ mod tests { #[test] fn the_portal_puts_the_broker_back_on_the_container_display() { let script = crate::provision::BROKER_DISPLAY_SCRIPT; - assert!(script.contains("DISPLAY=:99"), "the container's own display"); + assert!( + script.contains("DISPLAY=:99"), + "the container's own display" + ); assert!( script.contains("dbus-update-activation-environment"), "publishing it is what the broker inherits" diff --git a/src/termview.rs b/src/termview.rs index 95fac87..e2d2346 100644 --- a/src/termview.rs +++ b/src/termview.rs @@ -249,10 +249,7 @@ impl Viewport { /// How many source pixels the terminal covers at this scale. pub fn covered(&self, cols: u16, rows: u16) -> (f32, f32) { - ( - cols as f32 * self.scale, - rows as f32 * 2.0 * self.scale, - ) + (cols as f32 * self.scale, rows as f32 * 2.0 * self.scale) } /// Keep the view over the frame: no scrolling into the void, and a view @@ -421,7 +418,11 @@ pub enum Action { /// Send a named key. Press(Key), /// Left button at a terminal cell (`down` false is the release). - Click { col: u16, row: u16, down: bool }, + Click { + col: u16, + row: u16, + down: bool, + }, ZoomIn, ZoomOut, Fit, @@ -642,7 +643,10 @@ fn decode_mouse(buf: &[u8]) -> Escape { pub const HELP: &[(&str, &str)] = &[ ("type / Enter / Tab", "goes to the sign-in page"), ("click", "moves and clicks the pointer there"), - ("F2 / Ctrl+U", "zoom in — use it to read the Authenticator number"), + ( + "F2 / Ctrl+U", + "zoom in — use it to read the Authenticator number", + ), ("F3 / Ctrl+D", "zoom out"), ("F4 / Ctrl+W", "fit the whole screen"), ("F6", "actual size (one pixel per half cell)"), @@ -682,8 +686,14 @@ mod tests { // Within the scan's two-pixel step. assert!(found.x <= win.x && win.x - found.x <= 2, "{found:?}"); assert!(found.y <= win.y && win.y - found.y <= 2, "{found:?}"); - assert!(found.width >= win.width && found.width <= win.width + 4, "{found:?}"); - assert!(found.height >= win.height && found.height <= win.height + 4, "{found:?}"); + assert!( + found.width >= win.width && found.width <= win.width + 4, + "{found:?}" + ); + assert!( + found.height >= win.height && found.height <= win.height + 4, + "{found:?}" + ); } #[test] @@ -842,7 +852,10 @@ mod tests { let out = paint(Some(&a), &b, cols, rows); // One cell means one block character, and no other. assert_eq!(out.matches(BLOCK).count(), 1); - assert!(out.contains("\x1b[2;2H"), "did not address row 2 col 2: {out:?}"); + assert!( + out.contains("\x1b[2;2H"), + "did not address row 2 col 2: {out:?}" + ); assert!(out.contains("38;2;1;2;3")); } diff --git a/src/xscreen.rs b/src/xscreen.rs index 91be71d..567dbd0 100644 --- a/src/xscreen.rs +++ b/src/xscreen.rs @@ -195,7 +195,7 @@ impl XScreen { } fn decode(&self, data: &[u8]) -> Frame { - let bytes = (self.bits_per_pixel as usize + 7) / 8; + let bytes = (self.bits_per_pixel as usize).div_ceil(8); let mut rgb = Vec::with_capacity((self.width * self.height * 3) as usize); for y in 0..self.height as usize { for x in 0..self.width as usize { @@ -281,7 +281,9 @@ impl XScreen { let Ok(cookie) = self.conn.get_geometry(child) else { continue; }; - let Ok(geometry) = cookie.reply() else { continue }; + let Ok(geometry) = cookie.reply() else { + continue; + }; if geometry.width >= 80 && geometry.height >= 40 { return Ok(Some(child)); } @@ -474,7 +476,10 @@ mod tests { } } } - assert!(seen_white, "the window never appeared, or the pixel format is wrong"); + assert!( + seen_white, + "the window never appeared, or the pixel format is wrong" + ); screen.place_and_focus().expect("focus"); for c in format!("touch {proof}").chars() { diff --git a/src/xvfb.rs b/src/xvfb.rs index 4bfb94b..10b7b7d 100644 --- a/src/xvfb.rs +++ b/src/xvfb.rs @@ -29,7 +29,7 @@ impl Xvfb { pub fn start(width: u32, height: u32, number: Option) -> Result { let number = match number { Some(n) => n, - None => first_free_display(|n| display_taken(n)) + None => first_free_display(display_taken) .context("no free X display number between :77 and :99")?, }; // Xvfb's own diagnostics go to a file rather than to /dev/null: "Server is From 65a20800c822040a696c3b82a3476735ae3ac851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20WALLEZ?= Date: Wed, 12 Aug 2026 01:27:32 +0000 Subject: [PATCH 5/5] feat(login): serve the sign-in window to a browser over the tailnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal viewer draws the display with half-block cells, which costs half the vertical resolution: 13-pixel text survives that only when the reader zooms, and the two-digit Authenticator number is the one thing they came for. `login --web` serves the same display to a browser instead — at its own size, with a real pointer, and with Ctrl+V for a password out of a manager. Tailscale is the TRANSPORT, not the display. The broker still renders the sign-in in a WebKitGTK view on an X display, so xvfb.rs and xscreen.rs are untouched and webview.rs replaces termview.rs alone. The private display, the automation, the portal and the teardown are shared with the terminal path, which is why the broker-display rule from 2b26217 still holds for both. The server is deliberately small — one thread, no async runtime, no WebSocket: * `GET /delta` answers with the 32-pixel tiles that changed since the sequence number the client holds, gzipped, so the browser inflates it and the page needs no decoder. A stale sequence number gets a whole frame, which is the only answer that is right after a missed update; * `POST /input` is one request per keystroke, so a key never waits for a frame; * `poll(2)` over the listener and the open sockets keeps those independent without a thread each and without a partial-write queue. Sockets stay blocking for writes, with timeouts, rather than growing an output queue. It binds this host's tailnet address, read from `getifaddrs` rather than from the `tailscale` command, and falls back to loopback with the `ssh -L` line to reach it. Every request must carry a 128-bit token minted per session, from /dev/urandom and printed once: without it the socket would be open to every other device in the tailnet while a password is typed. `/favicon.ico` is the one exception, answered empty ahead of the check, because a browser asks for it with no token and the console error reads like a fault. Two failures found while proving it against a real browser: * the page read the flags and the tile count at the header offsets they had before width and height were added, so it drew the height as flags and started the tiles one field early. The unit test now asserts both sides of that header; * the favicon 403 above. Verified end to end, twice. `a_browser_client_sees_the_display_and_types_into_it` (ignored; needs Xvfb and xterm) drives the whole server over TCP: a wrong token and a missing one are refused, the first delta is the whole display, a white pixel proves the tiles carry it, a typed line reaches a window that is not ours, and Finish ends the session. Then the same server in a real Chromium: the xterm window drew sharp at 1024x768, four keystrokes arrived in it, and Finish returned the session with the X server and the window cleaned up. --- Cargo.toml | 5 +- README.md | 25 + src/lib.rs | 3 + src/login.rs | 63 ++- src/main.rs | 35 +- src/webview.rs | 1374 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1482 insertions(+), 23 deletions(-) create mode 100644 src/webview.rs diff --git a/Cargo.toml b/Cargo.toml index 8331f0b..8415b87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,10 @@ gui = [ # --- Core --- # `poll` is for the terminal sign-in: it waits for a keystroke without putting the # tty in non-blocking mode, which would also apply to the frames written back to it. -nix = { version = "0.29", features = ["process", "signal", "user", "fs", "mount", "sched", "hostname", "term", "poll"] } +# It also multiplexes the browser viewer's sockets, which is what keeps that server +# single-threaded. `net` is for `getifaddrs`: the browser viewer binds to the tailnet +# address, which is read from the interfaces rather than from the `tailscale` command. +nix = { version = "0.29", features = ["process", "signal", "user", "fs", "mount", "sched", "hostname", "term", "poll", "net"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index ef0f414..49b9b39 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,28 @@ It needs `Xvfb` on the host (`sudo apt install xvfb`) and a terminal with 24-bit colour. Everything it starts — the portal, the display forwarding, the X server — it stops again, including when your SSH connection drops. +#### Or in a browser, over your tailnet + +Half-block cells cost half the vertical resolution, and 13-pixel text survives that +only when you zoom. `--web` serves the same window to a browser instead, at its own +size and with a real pointer: + +```sh +intune-container login --web # prints one link, with a token for this session +``` + +It listens on this host's **tailnet** address, so any device in your tailnet can +open it — your phone included, which is where the Authenticator prompt arrives. With +no Tailscale on the host it binds loopback instead and prints the `ssh -L` line for +it. `--bind` and `--port` override both. Every request must carry the token from the +link; nothing else is served. + +The page is one file with no build step: a canvas, the tiles that changed since the +last frame (gzipped, so the browser inflates them), and your keys and clicks posted +back. **Ctrl+V** pastes — a password from your manager is typed into the display one +key at a time. **Finish** closes the window and ends the session, as **Ctrl+Q** does +in the terminal viewer. + Why not a plain text prompt? Because the identity broker allows none: it refuses a device-code flow on Linux (`AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform`) and renders the sign-in itself, in an embedded WebKitGTK view on @@ -154,6 +176,9 @@ shells out, and neither needs elevated privilege. - [x] **Terminal sign-in** (`login`) — for a host with no screen: the portal runs on a private invisible display and is drawn in the terminal, keys and clicks included. No VNC server, no compositor, no X forwarding. +- [x] **Browser sign-in over the tailnet** (`login --web`) — the same private + display, served to any device in your tailnet at full resolution, behind a + per-session token. One built-in HTTP server, no WebSocket and no VNC. - [x] **Enrollment backup/restore** — survive container rebuilds without re-enrolling. - [x] **Microsoft Edge** in the container, with display/GPU passthrough during diff --git a/src/lib.rs b/src/lib.rs index a62ad0a..e3d1ec8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,9 @@ pub mod xscreen; /// The private, invisible X display a terminal sign-in runs on. pub mod xvfb; +/// Serving that same display to a browser, over the tailnet (`login --web`). +pub mod webview; + /// Pure-Rust OCI image pull + layer extraction (no docker/podman). pub mod oci; diff --git a/src/login.rs b/src/login.rs index 1b6f633..7b1b870 100644 --- a/src/login.rs +++ b/src/login.rs @@ -17,6 +17,10 @@ //! keys and clicks back with XTEST ([`crate::xscreen`]). The reader types their //! address and password in the terminal and reads the Authenticator number there. //! +//! `--web` swaps that last part for a browser ([`crate::webview`]): the same +//! display, served over the tailnet at its own resolution. Everything else — the +//! private display, the portal, the automation, the teardown — is shared. +//! //! Everything it starts, it stops: the portal, the display forwarding and the X //! server are all torn down on the way out, whether the reader quits or the //! window closes. @@ -30,7 +34,7 @@ use nix::sys::termios::{self, SetArg, Termios}; use crate::termview::{self, Action, Cell, Frame, Key, Viewport}; use crate::xscreen::XScreen; use crate::xvfb::Xvfb; -use crate::{display, ops}; +use crate::{display, ops, webview}; /// How the reader asked for the session to look. pub struct Options { @@ -49,6 +53,10 @@ pub struct Options { /// Authenticator prompt and there is no field to fill. Measured on this tenant — /// typing an address at that point reaches nothing at all. pub credentials: Option, + /// Serve the window to a browser instead of drawing it in this terminal. The + /// display, the portal and the teardown are the same either way — only the + /// viewer changes. + pub web: Option, } impl Default for Options { @@ -62,6 +70,7 @@ impl Default for Options { display: None, automatic: true, credentials: None, + web: None, } } } @@ -104,9 +113,14 @@ const PORTAL_POLL: Duration = Duration::from_secs(3); /// Run the whole sign-in session. pub fn run(options: Options) -> Result<()> { - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + // Only the terminal viewer needs a terminal. `--web` draws nowhere near this + // tty, so it stays usable from a script, a systemd unit or a pipe. + if options.web.is_none() + && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()) + { anyhow::bail!( - "login needs a terminal on both stdin and stdout — run it directly, not through a pipe" + "login needs a terminal on both stdin and stdout — run it directly, not through a pipe, \ + or serve the window to a browser with --web" ); } @@ -135,11 +149,13 @@ pub fn run(options: Options) -> Result<()> { if options.automatic { automate(&screen, options.credentials.as_ref())?; } - print_keys(&display_name); - - let outcome = { - let tty = RawTty::enter()?; - view(&screen, &tty) + let outcome = match &options.web { + Some(web) => webview::serve(&screen, web), + None => { + print_keys(&display_name); + let tty = RawTty::enter()?; + view(&screen, &tty) + } }; // The tty is restored here, so everything below prints normally. @@ -395,11 +411,22 @@ fn catch_hangups() { } } -/// What the viewer ended on. -struct Session { +/// What the viewer ended on. Either viewer — the terminal one below, or the +/// browser one in [`crate::webview`] — reports the session the same way, so the +/// teardown in [`run`] is written once. +pub struct Session { /// True when the portal exited by itself (the reader closed the window), /// false when the reader quit the viewer with the window still open. - portal_gone: bool, + pub portal_gone: bool, +} + +/// Has the session been cut from outside (a dropped SSH connection, Ctrl+C)? +/// +/// Both viewers poll this rather than dying in the signal handler, so the ordinary +/// teardown runs: the portal is closed, the container goes back to headless and the +/// X server is stopped. +pub fn interrupted() -> bool { + INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed) } /// Draw the display and forward input until the reader quits or the window goes. @@ -408,11 +435,9 @@ fn view(screen: &XScreen, tty: &RawTty) -> Result { // Fit the WINDOW, not the desktop it sits on. The dialog is a third of the // screen, so fitting the screen shrank it past reading. let mut viewport = match screen.capture() { - Ok(frame) => Viewport::at_actual_size( - termview::content_bounds(&frame), - cols, - picture_rows(rows), - ), + Ok(frame) => { + Viewport::at_actual_size(termview::content_bounds(&frame), cols, picture_rows(rows)) + } Err(_) => Viewport::fitted(screen.width, screen.height, cols, picture_rows(rows)), }; let mut previous: Option> = None; @@ -551,7 +576,6 @@ fn view(screen: &XScreen, tty: &RawTty) -> Result { out.push_str(&status_line(&viewport, &frame, screen, cols, rows)); tty.write(&out)?; } - } } @@ -753,7 +777,10 @@ mod tests { let line = status_text(right, 80); assert!(line.ends_with(right), "the state was truncated: {line:?}"); assert!(line.contains(" "), "no gap between the halves: {line:?}"); - assert!(!line.contains("quit1:"), "the halves still collide: {line:?}"); + assert!( + !line.contains("quit1:"), + "the halves still collide: {line:?}" + ); // Narrower than the state alone: the state wins and nothing panics. let line = status_text(right, 12); assert_eq!(line.chars().count(), 12); diff --git a/src/main.rs b/src/main.rs index 86f2c6b..b28aba7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use std::io::IsTerminal; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use intune_container::{autostart, config, doctor, login, ops}; +use intune_container::{autostart, config, doctor, login, ops, webview}; #[derive(Parser)] #[command( @@ -103,6 +103,20 @@ enum Command { /// Use this X display number instead of the first free one #[arg(long, value_name = "N")] display: Option, + + /// Serve the window to a browser instead of drawing it here. It listens on + /// this host's tailnet address, so any device in your tailnet can open it. + #[arg(long)] + web: bool, + + /// Address the browser viewer listens on (default: the tailnet address, or + /// 127.0.0.1 when there is none) + #[arg(long, value_name = "ADDR", requires = "web")] + bind: Option, + + /// Port the browser viewer listens on + #[arg(long, value_name = "N", requires = "web")] + port: Option, }, /// Stop the container Stop, @@ -252,7 +266,17 @@ fn main() -> Result<()> { manual, geometry, display, - } => cmd_login(fill, email, manual, &geometry, display)?, + web, + bind, + port, + } => cmd_login( + fill, + email, + manual, + &geometry, + display, + web.then(|| webview::Options::resolved(bind, port)), + )?, Command::Stop => ops::stop()?, Command::Status => cmd_status()?, Command::Autostart { action } => match action { @@ -438,14 +462,16 @@ fn cmd_start() -> Result<()> { Ok(()) } -/// Sign in from this terminal: the command fills the form, the reader approves the -/// multi-factor prompt on their phone. +/// Sign in from this terminal — or, with `--web`, from a browser on the tailnet: +/// the command fills the form, the reader approves the multi-factor prompt on their +/// phone. fn cmd_login( fill: bool, email: Option, manual: bool, geometry: &str, display: Option, + web: Option, ) -> Result<()> { let (width, height) = login::parse_geometry(geometry)?; // Asked for BEFORE anything starts: a password prompt is the one thing that @@ -461,6 +487,7 @@ fn cmd_login( display, automatic: !manual, credentials, + web, }) } diff --git a/src/webview.rs b/src/webview.rs new file mode 100644 index 0000000..6670c9a --- /dev/null +++ b/src/webview.rs @@ -0,0 +1,1374 @@ +//! `login --web` — the same sign-in window, drawn in a browser over the tailnet. +//! +//! WHY, GIVEN THERE IS ALREADY A VIEWER. The terminal viewer draws the display +//! with half-block cells, which costs half the vertical resolution and all of the +//! sub-pixel detail: 13-pixel text survives it only when the reader zooms in, and +//! the two-digit Authenticator number is the one thing they came for. A browser +//! draws the same pixels at their own size, and a mouse is a real pointer rather +//! than a cell coordinate. +//! +//! WHAT IT DOES NOT CHANGE. The broker still renders the sign-in in a WebKitGTK +//! view on an X display, so the private [`Xvfb`](crate::xvfb) stays and so does +//! [`XScreen`](crate::xscreen): this module replaces [`crate::termview`], nothing +//! else. Tailscale is the transport and not the display — the window has to exist +//! either way. +//! +//! THE SERVER IS DELIBERATELY SMALL. One thread, no async runtime, no WebSocket: +//! +//! * the browser polls `GET /delta`, which answers with the tiles that changed +//! since the sequence number it holds (gzip, which every browser inflates on its +//! own — so the client does no decompression work); +//! * input goes back as `POST /input`, one request per keystroke, so a key never +//! waits for a frame; and +//! * `poll(2)` over the listener and the open sockets keeps those two independent +//! without a thread each, and without a partial-write queue. +//! +//! WHAT PROTECTS IT. The listener binds to the tailnet address (or to loopback +//! when there is none), so the socket is reachable only inside WireGuard, and +//! every request must carry a 128-bit token that is minted per session and printed +//! once. The token is what stops another device on the tailnet — or another user on +//! this host, for the loopback case — from watching a password being typed. + +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::os::fd::AsFd; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use tracing::debug; + +use crate::login::Session; +use crate::termview::{Frame, Key}; +use crate::xscreen::XScreen; +use crate::{login, ops}; + +/// The port the viewer listens on when none is given. +pub const DEFAULT_PORT: u16 = 6080; + +/// Where to serve the sign-in window. +pub struct Options { + pub bind: IpAddr, + pub port: u16, +} + +impl Options { + /// The address to listen on, preferring the tailnet: a viewer bound there is + /// reachable from every device in it and from nothing else, which is the whole + /// point of serving it rather than drawing it in the terminal. + pub fn resolved(bind: Option, port: Option) -> Self { + let bind = bind + .or_else(tailnet_address) + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + Self { + bind, + port: port.unwrap_or(DEFAULT_PORT), + } + } + + fn socket(&self) -> SocketAddr { + SocketAddr::new(self.bind, self.port) + } +} + +/// The edge of one update tile, in pixels. +/// +/// A whole 1280×800 frame is 3 MB and a caret blinking is a few hundred bytes of +/// it, so the picture is sent in tiles and only the ones that changed go out. 32 is +/// small enough that typing a character sends a strip rather than a screen, and +/// large enough that a full repaint is a thousand tiles rather than a hundred +/// thousand headers. +const TILE: u32 = 32; + +/// How stale a capture may be before `/delta` takes a new one. `GetImage` plus the +/// decode is the expensive part of a frame, so two requests arriving together share +/// one capture. +const MIN_CAPTURE: Duration = Duration::from_millis(70); + +/// How long `poll` waits when nothing is happening. The loop's whole idle cost. +const POLL_WAIT: Duration = Duration::from_millis(20); + +/// How long one request may take to arrive once its socket says it is readable. +/// A browser sends a request in one segment; this bounds what a stalled one can +/// cost the single-threaded loop. +const READ_TIMEOUT: Duration = Duration::from_millis(200); + +/// How long a frame may take to go out before the client is dropped. +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How often the container is asked whether the portal is still up. A subprocess +/// probe, so deliberately much rarer than a frame. +const PORTAL_POLL: Duration = Duration::from_secs(3); + +/// A browser opens a handful of connections and keeps them alive. The cap is what +/// stops an abandoned session from holding file descriptors for ever. +const MAX_CLIENTS: usize = 8; + +/// Serve the display until the reader finishes, the portal closes, or the session +/// is cut from outside. +pub fn serve(screen: &XScreen, options: &Options) -> Result { + let token = new_token()?; + let listener = TcpListener::bind(options.socket()) + .with_context(|| format!("cannot listen on {}", options.socket()))?; + print_url(options, &token); + serve_on(screen, listener, token) +} + +/// The session loop, on a listener that is already bound. +/// +/// Split from [`serve`] so a test can drive the whole server — page, deltas, input +/// and the end of the session — on a port of its own and with a token it knows. +fn serve_on(screen: &XScreen, listener: TcpListener, token: String) -> Result { + listener + .set_nonblocking(true) + .context("cannot make the listener non-blocking")?; + + let mut server = Server { + screen, + token, + sent: None, + seq: 0, + current: None, + captured: Instant::now() - MIN_CAPTURE, + finished: false, + portal_gone: false, + }; + let mut clients: Vec = Vec::new(); + let mut last_probe = Instant::now(); + let mut portal_seen = false; + + loop { + if login::interrupted() { + return Ok(Session { portal_gone: false }); + } + if server.finished { + return Ok(Session { portal_gone: false }); + } + + // The portal going is the end of the session: the reader closed the window, + // or it closed itself once the sign-in was complete. + if last_probe.elapsed() >= PORTAL_POLL { + last_probe = Instant::now(); + if ops::portal_is_running() { + portal_seen = true; + } else if portal_seen { + server.portal_gone = true; + // Let the page hear about it before the socket closes under it. + serve_once(&mut server, &mut clients, Duration::from_millis(300)); + return Ok(Session { portal_gone: true }); + } + } + + accept(&listener, &mut clients); + serve_once(&mut server, &mut clients, POLL_WAIT); + } +} + +/// Take whatever connections are waiting, dropping the oldest when the cap is +/// reached — an abandoned tab must not lock out the reader's new one. +fn accept(listener: &TcpListener, clients: &mut Vec) { + while let Ok((stream, from)) = listener.accept() { + if stream.set_nonblocking(false).is_err() { + continue; + } + let _ = stream.set_read_timeout(Some(READ_TIMEOUT)); + let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT)); + let _ = stream.set_nodelay(true); + if clients.len() >= MAX_CLIENTS { + clients.remove(0); + } + debug!(%from, "a viewer connected"); + clients.push(stream); + } +} + +/// Wait up to `wait` for a request on any open connection, and answer the ones +/// that arrived. +fn serve_once(server: &mut Server, clients: &mut Vec, wait: Duration) { + use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; + + if clients.is_empty() { + std::thread::sleep(wait); + return; + } + + let ready: Vec = { + let mut fds: Vec = clients + .iter() + .map(|c| PollFd::new(c.as_fd(), PollFlags::POLLIN)) + .collect(); + let millis = wait.as_millis().min(u16::MAX as u128) as u16; + match poll(&mut fds, PollTimeout::from(millis)) { + Ok(0) | Err(_) => return, + Ok(_) => fds + .iter() + .enumerate() + .filter(|(_, fd)| fd.revents().is_some_and(|r| !r.is_empty())) + .map(|(i, _)| i) + .collect(), + } + }; + + let mut done = Vec::new(); + for index in ready { + let keep = match read_request(&mut clients[index]) { + Ok(Some(request)) => server.answer(&mut clients[index], &request).is_ok(), + // A closed or unreadable connection: the browser reopens one. + _ => false, + }; + if !keep { + done.push(index); + } + } + for index in done.into_iter().rev() { + clients.remove(index); + } +} + +/// The viewer's state between requests. +struct Server<'a> { + screen: &'a XScreen, + token: String, + /// The frame the client holds, which is what a delta is computed against. + /// + /// Shared rather than copied: a frame is three megabytes, and the same one is + /// both the last capture and the last thing sent. + sent: Option>, + /// Bumped for every delta sent. A client whose sequence does not match gets a + /// whole frame, which is the only answer that is right when an update was + /// missed. + seq: u32, + current: Option>, + captured: Instant, + /// The reader pressed Finish. + finished: bool, + /// The portal has gone; the page is told so it can stop polling. + portal_gone: bool, +} + +/// The magic word at the head of a delta: `ICV1`, so a stale client or a stray +/// proxy response is rejected rather than drawn as noise. +const MAGIC: u32 = 0x4943_5631; + +/// A window is on the display. +const FLAG_WINDOW: u16 = 1; +/// The session is over. +const FLAG_GONE: u16 = 2; + +impl Server<'_> { + fn answer(&mut self, stream: &mut TcpStream, request: &Request) -> std::io::Result<()> { + // The one request a browser makes on its own, and the only one that cannot + // carry the token. Answered with nothing, ahead of the check, so the page's + // console holds no error to read past. It says no more than the open port + // already does. + if request.path == "/favicon.ico" { + return respond(stream, "204 No Content", "text/plain", &[], b""); + } + if !constant_time_eq(request.token.as_bytes(), self.token.as_bytes()) { + return respond(stream, "403 Forbidden", "text/plain", &[], b"wrong token\n"); + } + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/") => { + let page = page(self.screen.width, self.screen.height); + respond( + stream, + "200 OK", + "text/html; charset=utf-8", + &[], + page.as_bytes(), + ) + } + ("GET", "/delta") => self.delta(stream, request), + ("POST", "/input") => { + let applied = self.apply(&request.body); + match applied { + Ok(()) => respond(stream, "200 OK", "text/plain", &[], b"ok\n"), + Err(e) => { + debug!("input refused: {e:#}"); + respond(stream, "400 Bad Request", "text/plain", &[], b"bad input\n") + } + } + } + ("POST", "/quit") => { + self.finished = true; + respond(stream, "200 OK", "text/plain", &[], b"closing\n") + } + _ => respond( + stream, + "404 Not Found", + "text/plain", + &[], + b"no such path\n", + ), + } + } + + /// Answer with the tiles that changed since the client's sequence number. + fn delta(&mut self, stream: &mut TcpStream, request: &Request) -> std::io::Result<()> { + let client_seq: u32 = request + .query("seq") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let frame = match self.capture() { + Ok(frame) => frame, + Err(e) => { + debug!("cannot read the display: {e:#}"); + return respond( + stream, + "503 Service Unavailable", + "text/plain", + &[], + b"the display did not answer\n", + ); + } + }; + + // A stale sequence number means an update was missed, and a diff against a + // frame the client never drew would leave the picture wrong for ever. + let against = if client_seq == self.seq { + self.sent.clone() + } else { + None + }; + let tiles = changed_tiles(against.as_deref(), &frame); + + let mut flags = 0; + if self.screen.has_window() { + flags |= FLAG_WINDOW; + } + if self.portal_gone { + flags |= FLAG_GONE; + } + if !tiles.is_empty() { + self.seq = self.seq.wrapping_add(1); + self.sent = Some(frame.clone()); + } + let body = encode(self.seq, &frame, flags, &tiles); + + let body = gzip(&body)?; + respond( + stream, + "200 OK", + "application/octet-stream", + &["Content-Encoding: gzip"], + &body, + ) + } + + /// The display as it is now, re-reading it only when the last read is stale. + fn capture(&mut self) -> Result> { + if self.current.is_none() || self.captured.elapsed() >= MIN_CAPTURE { + // Before the picture, as in the terminal viewer: with no window manager + // on this display, nothing else moves a new window into view or gives it + // the keyboard. + self.screen.place_and_focus()?; + self.current = Some(Arc::new(self.screen.capture()?)); + self.captured = Instant::now(); + } + Ok(self.current.clone().expect("just captured")) + } + + /// Apply one batch of input events. + fn apply(&mut self, body: &[u8]) -> Result<()> { + let events: Vec = + serde_json::from_slice(body).context("the input was not a list of events")?; + for event in events { + match event { + Event::Text { v } => { + self.screen.place_and_focus()?; + let refused = self.screen.type_text(&v)?; + if !refused.is_empty() { + debug!("this display's keyboard cannot type {refused:?}"); + } + } + Event::Key { v } => { + let key = named_key(&v) + .with_context(|| format!("{v:?} is not a key this viewer sends"))?; + self.screen.place_and_focus()?; + self.screen.press(key)?; + } + Event::Mouse { x, y, down } => { + self.screen.click(x, y, down)?; + // A click is how a window gets the focus with no window manager + // around, so let it, then take the keyboard back. + if !down { + self.screen.place_and_focus()?; + } + } + } + } + Ok(()) + } +} + +/// What the page sends back. Text and keys are separate because a printable +/// character is a keysym the display's map already holds, while a named key is one +/// of the few the viewer knows how to send. +#[derive(Debug, Deserialize)] +#[serde(tag = "t", rename_all = "lowercase")] +enum Event { + Text { v: String }, + Key { v: String }, + Mouse { x: i16, y: i16, down: bool }, +} + +/// The named keys the page may ask for, spelled as [`Key`] spells them. +fn named_key(name: &str) -> Option { + Some(match name { + "Return" => Key::Return, + "Space" => Key::Space, + "Tab" => Key::Tab, + "BackTab" => Key::BackTab, + "BackSpace" => Key::BackSpace, + "Delete" => Key::Delete, + "Escape" => Key::Escape, + "Left" => Key::Left, + "Right" => Key::Right, + "Up" => Key::Up, + "Down" => Key::Down, + "Home" => Key::Home, + "End" => Key::End, + "PageUp" => Key::PageUp, + "PageDown" => Key::PageDown, + _ => return None, + }) +} + +/// One rectangle of the picture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Tile { + x: u32, + y: u32, + width: u32, + height: u32, +} + +/// The tiles of `current` that differ from `previous`. All of them when there is no +/// previous frame, or when the display was resized under us. +fn changed_tiles(previous: Option<&Frame>, current: &Frame) -> Vec { + let previous = previous.filter(|p| p.width == current.width && p.height == current.height); + let mut tiles = Vec::new(); + let mut y = 0; + while y < current.height { + let height = TILE.min(current.height - y); + let mut x = 0; + while x < current.width { + let width = TILE.min(current.width - x); + let tile = Tile { + x, + y, + width, + height, + }; + if previous.is_none_or(|p| differs(p, current, &tile)) { + tiles.push(tile); + } + x += TILE; + } + y += TILE; + } + tiles +} + +/// Does one tile differ between two frames of the same size? +fn differs(a: &Frame, b: &Frame, tile: &Tile) -> bool { + for row in 0..tile.height { + let start = ((tile.y + row) * a.width + tile.x) as usize * 3; + let len = tile.width as usize * 3; + match (a.rgb.get(start..start + len), b.rgb.get(start..start + len)) { + (Some(left), Some(right)) if left == right => {} + // A row that is not there in one of them counts as a difference: a short + // buffer is better redrawn than trusted. + _ => return true, + } + } + false +} + +/// Pack a delta: a fixed header, then every tile with its own rectangle. +/// +/// Raw RGB rather than PNG or JPEG, because the response is gzipped anyway and the +/// picture is a form on a flat background — the kind of content deflate is good at. +/// It also keeps the client free of a decoder. +fn encode(seq: u32, frame: &Frame, flags: u16, tiles: &[Tile]) -> Vec { + let mut out = Vec::with_capacity(16 + tiles.len() * (8 + (TILE * TILE * 3) as usize)); + out.extend_from_slice(&MAGIC.to_be_bytes()); + out.extend_from_slice(&seq.to_be_bytes()); + out.extend_from_slice(&(frame.width as u16).to_be_bytes()); + out.extend_from_slice(&(frame.height as u16).to_be_bytes()); + out.extend_from_slice(&flags.to_be_bytes()); + out.extend_from_slice(&(tiles.len() as u16).to_be_bytes()); + for tile in tiles { + out.extend_from_slice(&(tile.x as u16).to_be_bytes()); + out.extend_from_slice(&(tile.y as u16).to_be_bytes()); + out.extend_from_slice(&(tile.width as u16).to_be_bytes()); + out.extend_from_slice(&(tile.height as u16).to_be_bytes()); + for row in 0..tile.height { + let start = ((tile.y + row) * frame.width + tile.x) as usize * 3; + let len = tile.width as usize * 3; + match frame.rgb.get(start..start + len) { + Some(bytes) => out.extend_from_slice(bytes), + None => out.extend(std::iter::repeat_n(0u8, len)), + } + } + } + out +} + +/// Compress a body the way every browser can already inflate one, so the client +/// needs no decompression code of its own. +fn gzip(body: &[u8]) -> std::io::Result> { + use flate2::write::GzEncoder; + use flate2::Compression; + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(body)?; + encoder.finish() +} + +/// One HTTP request, reduced to the four things this server reads. +#[derive(Debug, Default)] +struct Request { + method: String, + path: String, + query: String, + token: String, + body: Vec, +} + +impl Request { + fn query(&self, name: &str) -> Option<&str> { + query_value(&self.query, name) + } +} + +/// One value from a query string. No percent-decoding: every value this server +/// reads is a number or a hex token. +fn query_value<'a>(query: &'a str, name: &str) -> Option<&'a str> { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then_some(value) + }) +} + +/// Headers are bounded so a client cannot make the server hold a large buffer, and +/// the body is bounded well above the largest thing the page sends (a pasted +/// password). +const MAX_HEADERS: usize = 8 * 1024; +const MAX_BODY: usize = 64 * 1024; + +/// Read one request from a socket that has said it is readable. +fn read_request(stream: &mut TcpStream) -> Result> { + let mut buffer = Vec::new(); + let mut chunk = [0u8; 2048]; + let head = loop { + if let Some(at) = find(&buffer, b"\r\n\r\n") { + break at; + } + if buffer.len() > MAX_HEADERS { + anyhow::bail!("the request headers are too large"); + } + match stream.read(&mut chunk) { + Ok(0) => return Ok(None), + Ok(n) => buffer.extend_from_slice(&chunk[..n]), + Err(e) => return Err(e).context("cannot read the request"), + } + }; + + let parsed = parse_head(&String::from_utf8_lossy(&buffer[..head])); + if parsed.length > MAX_BODY { + anyhow::bail!("the request body is too large"); + } + + let mut body = buffer[head + 4..].to_vec(); + while body.len() < parsed.length { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => body.extend_from_slice(&chunk[..n]), + Err(e) => return Err(e).context("cannot read the request body"), + } + } + body.truncate(parsed.length); + + Ok(Some(Request { + method: parsed.method, + path: parsed.path, + query: parsed.query, + token: parsed.token, + body, + })) +} + +/// What the request line and the headers said. +#[derive(Debug, Default, PartialEq, Eq)] +struct Head { + method: String, + path: String, + query: String, + token: String, + length: usize, +} + +/// Read the request line and the two headers this server acts on. +/// +/// Anything else is ignored on purpose: the only client is the page below, and a +/// server that parsed more of HTTP than it needs would be more to get wrong. +fn parse_head(text: &str) -> Head { + let mut lines = text.lines(); + let start = lines.next().unwrap_or_default(); + let mut words = start.split_whitespace(); + let method = words.next().unwrap_or_default().to_string(); + let target = words.next().unwrap_or_default(); + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path.to_string(), query.to_string()), + None => (target.to_string(), String::new()), + }; + + let mut head = Head { + method, + path, + query, + ..Head::default() + }; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + head.length = value.parse().unwrap_or(0); + } else if name.eq_ignore_ascii_case("x-token") { + head.token = value.to_string(); + } + } + // The page is opened from a link, so the first token can only come in the URL; + // every request the page makes afterwards sends it as a header. + if head.token.is_empty() { + head.token = query_value(&head.query, "k") + .unwrap_or_default() + .to_string(); + } + head +} + +/// The first index of `needle` in `haystack`. +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// Write one response and keep the connection open for the next request. +fn respond( + stream: &mut TcpStream, + status: &str, + content_type: &str, + extra: &[&str], + body: &[u8], +) -> std::io::Result<()> { + let mut head = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Cache-Control: no-store\r\nConnection: keep-alive\r\n", + body.len() + ); + for line in extra { + head.push_str(line); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes())?; + stream.write_all(body)?; + stream.flush() +} + +/// Compare two secrets without leaking where they first differ. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() || a.is_empty() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// A fresh 128-bit session token, from the kernel rather than from a clock: it is +/// the only thing between the tailnet and a password being typed. +fn new_token() -> Result { + let mut bytes = [0u8; 16]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut bytes)) + .context("cannot read /dev/urandom for a session token")?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// This host's tailnet address, if Tailscale is up. +/// +/// Read from the interfaces rather than from the `tailscale` command, because the +/// CLI is not always installed next to the daemon and this needs no subprocess. +pub fn tailnet_address() -> Option { + let addresses = nix::ifaddrs::getifaddrs().ok()?; + for interface in addresses { + if !interface.interface_name.starts_with("tailscale") { + continue; + } + let Some(address) = interface.address.and_then(|a| a.as_sockaddr_in().copied()) else { + continue; + }; + let ip = address.ip(); + if is_tailnet(ip) { + return Some(IpAddr::V4(ip)); + } + } + None +} + +/// Is this one of the addresses Tailscale hands out? `100.64.0.0/10`, the shared +/// address space it uses for a tailnet. +fn is_tailnet(ip: Ipv4Addr) -> bool { + let [a, b, ..] = ip.octets(); + a == 100 && (64..128).contains(&b) +} + +/// Print the one link that opens the session, and say what it is reachable from. +fn print_url(options: &Options, token: &str) { + let url = format!("http://{}:{}/?k={token}", options.bind, options.port); + eprintln!(); + eprintln!("Open the sign-in window in a browser:"); + eprintln!(); + eprintln!(" {url}"); + eprintln!(); + if options.bind.is_loopback() { + eprintln!("It listens on loopback only. Reach it from your own machine with:"); + eprintln!(" ssh -N -L {0}:127.0.0.1:{0} ", options.port); + } else { + eprintln!("It listens on the tailnet, so every device in yours can open it — a phone"); + eprintln!("included, which is where the Authenticator prompt arrives."); + } + eprintln!(); + eprintln!("The link holds a token that is new for this session. Anything without it is"); + eprintln!("refused. Press Ctrl+C here, or Finish in the page, to close the window."); + eprintln!(); +} + +/// The page: a canvas, a poll loop, and the keyboard and mouse sent back. +/// +/// It is one file with no build step and no dependency on purpose — the CLI is a +/// single static binary, and a viewer that needed a bundler would not survive the +/// way this crate is published. +fn page(width: u32, height: u32) -> String { + format!( + r#" + + + + +Intune sign-in + + + +
+
+ connecting… + click the page, then type — Ctrl+V pastes + +
+ + + +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(width: u32, height: u32, colour: [u8; 3]) -> Frame { + Frame::flat(width, height, colour) + } + + #[test] + fn a_first_frame_sends_every_tile() { + // 64×64 with a 32-pixel tile is exactly four tiles, and a client that holds + // nothing has to be sent all of them. + let current = frame(64, 64, [0, 0, 0]); + assert_eq!(changed_tiles(None, ¤t).len(), 4); + } + + #[test] + fn an_unchanged_frame_sends_nothing() { + let a = frame(64, 64, [10, 20, 30]); + let b = frame(64, 64, [10, 20, 30]); + assert!(changed_tiles(Some(&a), &b).is_empty()); + } + + #[test] + fn one_changed_pixel_sends_only_its_tile() { + let previous = frame(64, 64, [0, 0, 0]); + let mut current = previous.clone(); + // A pixel at 40,40 sits in the tile at 32,32. + let at = ((40 * 64) + 40) * 3; + current.rgb[at] = 255; + let tiles = changed_tiles(Some(&previous), ¤t); + assert_eq!( + tiles, + vec![Tile { + x: 32, + y: 32, + width: 32, + height: 32 + }] + ); + } + + #[test] + fn a_resized_display_is_redrawn_whole() { + // Diffing against a frame of another size would read the wrong rows. + let previous = frame(64, 64, [0, 0, 0]); + let current = frame(96, 64, [0, 0, 0]); + assert_eq!(changed_tiles(Some(&previous), ¤t).len(), 6); + } + + #[test] + fn an_edge_tile_is_clipped_to_the_display() { + // 40 pixels is one whole tile and one 8-pixel remainder; a tile that ran + // past the edge would read another row's pixels. + let current = frame(40, 32, [0, 0, 0]); + let tiles = changed_tiles(None, ¤t); + assert_eq!(tiles.len(), 2); + assert_eq!(tiles[1].width, 8); + assert_eq!(tiles[0].height, 32); + } + + #[test] + fn a_delta_says_what_it_holds() { + let current = frame(64, 32, [1, 2, 3]); + let tiles = changed_tiles(None, ¤t); + let body = encode(7, ¤t, FLAG_WINDOW, &tiles); + assert_eq!(u32::from_be_bytes(body[0..4].try_into().unwrap()), MAGIC); + assert_eq!(u32::from_be_bytes(body[4..8].try_into().unwrap()), 7); + assert_eq!(u16::from_be_bytes(body[8..10].try_into().unwrap()), 64); + assert_eq!(u16::from_be_bytes(body[10..12].try_into().unwrap()), 32); + assert_eq!( + u16::from_be_bytes(body[12..14].try_into().unwrap()), + FLAG_WINDOW + ); + assert_eq!( + u16::from_be_bytes(body[14..16].try_into().unwrap()), + tiles.len() as u16 + ); + // Header, then two tiles of 32×32 pixels with a rectangle each. The page + // reads the same offsets, so a header that grows here breaks that test too. + assert_eq!(body.len(), 16 + 2 * (8 + 32 * 32 * 3)); + let html = page(64, 32); + assert!( + html.contains("view.getUint16(12)"), + "the page reads the flags" + ); + assert!( + html.contains("view.getUint16(14)"), + "the page reads the count" + ); + assert!(html.contains("let at = 16"), "the page skips the header"); + } + + #[test] + fn a_request_line_is_split_into_a_path_and_a_query() { + let head = parse_head("GET /delta?seq=12 HTTP/1.1\r\nX-Token: abc\r\nAccept: */*"); + assert_eq!(head.method, "GET"); + assert_eq!(head.path, "/delta"); + assert_eq!(head.query, "seq=12"); + assert_eq!(head.token, "abc"); + assert_eq!(head.length, 0); + assert_eq!(query_value(&head.query, "seq"), Some("12")); + assert_eq!(query_value(&head.query, "k"), None); + } + + #[test] + fn a_header_name_is_read_whatever_its_case() { + // Nothing promises a browser spells a header the way this one does. + let head = parse_head("POST /input HTTP/1.1\r\ncontent-length: 42\r\nx-token: beef"); + assert_eq!(head.length, 42); + assert_eq!(head.token, "beef"); + } + + #[test] + fn the_url_carries_the_token_when_no_header_does() { + // The first request is a link the reader opened, which can only carry it there. + let head = parse_head("GET /?k=cafe HTTP/1.1\r\nHost: x"); + assert_eq!(head.path, "/"); + assert_eq!(head.token, "cafe"); + } + + #[test] + fn a_header_wins_over_the_url() { + let head = parse_head("GET /delta?k=stale HTTP/1.1\r\nX-Token: fresh"); + assert_eq!(head.token, "fresh"); + } + + #[test] + fn the_end_of_the_headers_is_found() { + let raw = b"GET / HTTP/1.1\r\nHost: x\r\n\r\nbody"; + assert_eq!(find(raw, b"\r\n\r\n"), Some(23)); + assert_eq!(find(b"no end here", b"\r\n\r\n"), None); + } + + #[test] + fn a_token_is_compared_whole() { + assert!(constant_time_eq(b"abcd", b"abcd")); + assert!(!constant_time_eq(b"abcd", b"abce")); + assert!(!constant_time_eq(b"abcd", b"abc")); + // An empty token must never match, or a request with none would pass. + assert!(!constant_time_eq(b"", b"")); + } + + #[test] + fn a_session_token_is_new_every_time() { + let one = new_token().expect("a token"); + let two = new_token().expect("a token"); + assert_eq!(one.len(), 32); + assert!(one.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(one, two); + } + + #[test] + fn only_the_tailnet_range_counts_as_one() { + assert!(is_tailnet(Ipv4Addr::new(100, 80, 26, 90))); + assert!(is_tailnet(Ipv4Addr::new(100, 64, 0, 1))); + assert!(is_tailnet(Ipv4Addr::new(100, 127, 255, 255))); + // 100.128.x is outside 100.64.0.0/10, and 10.x is an ordinary private net. + assert!(!is_tailnet(Ipv4Addr::new(100, 128, 0, 1))); + assert!(!is_tailnet(Ipv4Addr::new(100, 63, 255, 255))); + assert!(!is_tailnet(Ipv4Addr::new(10, 0, 0, 1))); + } + + #[test] + fn a_given_address_wins_over_the_tailnet_one() { + let asked = IpAddr::V4(Ipv4Addr::LOCALHOST); + let options = Options::resolved(Some(asked), Some(7000)); + assert_eq!(options.bind, asked); + assert_eq!(options.port, 7000); + assert_eq!(options.socket().to_string(), "127.0.0.1:7000"); + } + + #[test] + fn the_default_port_is_used_when_none_is_given() { + let options = Options::resolved(Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), None); + assert_eq!(options.port, DEFAULT_PORT); + } + + #[test] + fn every_key_the_page_sends_is_known() { + // The page's own table, which must not drift from the one above it. + for name in [ + "Return", + "Tab", + "BackTab", + "BackSpace", + "Delete", + "Escape", + "Left", + "Right", + "Up", + "Down", + "Home", + "End", + "PageUp", + "PageDown", + "Space", + ] { + assert!(named_key(name).is_some(), "{name} is not mapped"); + } + assert!(named_key("F13").is_none()); + assert!(named_key("").is_none()); + } + + #[test] + fn the_page_names_the_display_size_and_the_magic_word() { + let html = page(1280, 800); + assert!(html.contains(r#"width="1280""#)); + assert!(html.contains(r#"height="800""#)); + // The client checks the same magic word the encoder writes. + assert!(html.contains("0x49435631")); + // The braces of the script survived the format string. + assert!(html.contains("const TOKEN")); + assert!(!html.contains("{{")); + } + + #[test] + fn input_is_read_as_the_page_sends_it() { + let body = br#"[{"t":"text","v":"ab"},{"t":"key","v":"Return"}, + {"t":"mouse","x":10,"y":20,"down":true}]"#; + let events: Vec = serde_json::from_slice(body).expect("the events"); + assert_eq!(events.len(), 3); + assert!(matches!(&events[0], Event::Text { v } if v == "ab")); + assert!(matches!(&events[1], Event::Key { v } if v == "Return")); + assert!(matches!( + events[2], + Event::Mouse { + x: 10, + y: 20, + down: true + } + )); + } + + /// One request over a fresh connection, with the body inflated when it came + /// gzipped. Enough HTTP to be a browser for the length of a test. + fn ask(addr: SocketAddr, request: &str) -> (u16, Vec) { + use std::io::Read as _; + let mut stream = TcpStream::connect(addr).expect("connect to the viewer"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("a read timeout"); + stream + .write_all(request.as_bytes()) + .expect("write the request"); + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + let mut head = None; + let mut length = 0usize; + loop { + match head { + None => { + if let Some(at) = find(&raw, b"\r\n\r\n") { + let parsed = String::from_utf8_lossy(&raw[..at]).to_string(); + for line in parsed.lines() { + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap_or(0); + } + } + } + head = Some((at, parsed)); + continue; + } + } + Some((at, _)) if raw.len() >= at + 4 + length => break, + _ => {} + } + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => raw.extend_from_slice(&chunk[..n]), + Err(e) => panic!("the viewer stopped answering: {e}"), + } + } + let (at, parsed) = head.expect("a complete response head"); + let status: u16 = parsed + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse().ok()) + .expect("a status code"); + let body = raw[at + 4..].to_vec(); + if parsed.to_lowercase().contains("content-encoding: gzip") { + let mut back = Vec::new(); + flate2::read::GzDecoder::new(&body[..]) + .read_to_end(&mut back) + .expect("inflate the body"); + return (status, back); + } + (status, body) + } + + /// The whole server, against a real display and a real window: the page is + /// served, the picture arrives, a typed line reaches a window that is not ours, + /// and Finish ends the session. + /// + /// Ignored by default because it needs `Xvfb` and `xterm` — run it with + /// `cargo test --lib -- --ignored --nocapture a_browser_client`. No unit test can + /// catch what this one can: a header the page reads at the wrong offset, a token + /// check that lets a request through, or input that goes nowhere. + #[test] + #[ignore = "needs Xvfb and xterm on this machine"] + fn a_browser_client_sees_the_display_and_types_into_it() { + use std::process::{Command, Stdio}; + + for tool in ["Xvfb", "xterm"] { + let missing = Command::new("sh") + .arg("-c") + .arg(format!("command -v {tool}")) + .stdout(Stdio::null()) + .status() + .map(|s| !s.success()) + .unwrap_or(true); + if missing { + eprintln!("skipped: {tool} is not installed"); + return; + } + } + + let proof = format!("/tmp/intune-container-webview-{}", std::process::id()); + let _ = std::fs::remove_file(&proof); + + let xvfb = crate::xvfb::Xvfb::start(800, 600, None).expect("start Xvfb"); + let screen = XScreen::connect(&xvfb.display()).expect("connect to the display"); + let mut xterm = Command::new("xterm") + .env("DISPLAY", xvfb.display()) + .args(["-geometry", "80x24+0+0", "-bg", "white", "-fg", "black"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn xterm"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind a test port"); + let addr = listener.local_addr().expect("the test port"); + let token = "0123456789abcdef0123456789abcdef"; + + let expect = proof.clone(); + let client = std::thread::spawn(move || { + let get = |path: &str, token: &str| { + ask( + addr, + &format!( + "GET {path} HTTP/1.1\r\nHost: test\r\nX-Token: {token}\r\nConnection: close\r\n\r\n" + ), + ) + }; + + // A request with the wrong token, and one with none, must both be + // refused before anything of the display is sent. + let (status, _) = get("/delta?seq=0", "not-the-token"); + assert_eq!(status, 403, "a wrong token was served"); + let (status, _) = get("/delta?seq=0", ""); + assert_eq!(status, 403, "a missing token was served"); + // The browser asks for this one by itself, with no token to give. + let (status, _) = get("/favicon.ico", ""); + assert_eq!(status, 204, "the favicon left an error in the console"); + + let (status, page) = get("/", token); + assert_eq!(status, 200); + let page = String::from_utf8_lossy(&page); + assert!(page.contains(" 0, "the first delta carried no sequence number"); + + // xterm's background is white, so the picture must hold a white pixel: + // that is the proof the tiles carry the display and not zeroes. + let mut white = false; + for _ in 0..100 { + let (_, body) = get("/delta?seq=0", token); + let flags = u16::from_be_bytes(body[12..14].try_into().unwrap()); + white = body[16..].chunks(3).any(|p| p == [255, 255, 255]); + if white && flags & FLAG_WINDOW != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(white, "the window never appeared in a delta"); + + // Type into a window that belongs to xterm, not to us. + let events = + format!(r#"[{{"t":"text","v":"touch {expect}"}},{{"t":"key","v":"Return"}}]"#); + let (status, _) = ask( + addr, + &format!( + "POST /input HTTP/1.1\r\nHost: test\r\nX-Token: {token}\r\n\ + Content-Type: application/json\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{events}", + events.len() + ), + ); + assert_eq!(status, 200, "the input was refused"); + + let mut landed = false; + for _ in 0..100 { + if std::path::Path::new(&expect).exists() { + landed = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let (status, _) = ask( + addr, + &format!( + "POST /quit HTTP/1.1\r\nHost: test\r\nX-Token: {token}\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ), + ); + assert_eq!(status, 200); + landed + }); + + // The server runs here, in the thread that owns the X connection, and + // returns when the client presses Finish. + let session = serve_on(&screen, listener, token.to_string()).expect("serve"); + let landed = client.join().expect("the client thread"); + + let _ = xterm.kill(); + let _ = xterm.wait(); + let _ = std::fs::remove_file(&proof); + drop(xvfb); + + assert!(!session.portal_gone, "no portal was running in this test"); + assert!( + landed, + "the typed line never reached the window — the input path is broken" + ); + } + + #[test] + fn a_gzip_body_carries_the_bytes_back() { + use std::io::Read as _; + let body = encode(1, &frame(64, 32, [9, 9, 9]), 0, &[]); + let packed = gzip(&body).expect("gzip"); + let mut back = Vec::new(); + flate2::read::GzDecoder::new(&packed[..]) + .read_to_end(&mut back) + .expect("inflate"); + assert_eq!(back, body); + } +}