diff --git a/Cargo.lock b/Cargo.lock index 4b843a3..32a26cc 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 09a5ea6..7605120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,12 @@ 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. +# 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" @@ -69,6 +74,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 b369383..b549571 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,54 @@ intune-container start # (optional) run headless + seamless Teams/M365 SSO Daily CLI use: `edge` · `status` · `update` · `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. + +#### 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 +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. Each @@ -127,6 +175,12 @@ 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] **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 7c67725..e3d1ec8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,9 +20,22 @@ 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; + +/// 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 new file mode 100644 index 0000000..7b1b870 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,818 @@ +//! `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. +//! +//! `--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. + +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, Key, Viewport}; +use crate::xscreen::XScreen; +use crate::xvfb::Xvfb; +use crate::{display, ops, webview}; + +/// How the reader asked for the session to look. +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, + /// 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 { + 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, + automatic: true, + credentials: None, + web: 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<()> { + // 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, \ + or serve the window to a browser with --web" + ); + } + + 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, + }; + + 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())?; + } + 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. + 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(()) +} + +/// 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. +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. 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. + 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. +fn view(screen: &XScreen, tty: &RawTty) -> Result { + let (mut cols, mut rows) = tty.size(); + // 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; + 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 => { + // 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; + 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 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!(); +} + +/// 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 c4b716e..5c7e085 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, webview}; #[derive(Parser)] #[command( @@ -80,6 +80,44 @@ enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// 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, + + /// 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, /// Re-pull the container image when the registry has a newer build @@ -232,6 +270,23 @@ fn main() -> Result<()> { Command::Enroll { image } => cmd_enroll(image)?, Command::Start => cmd_start()?, Command::Edge { verbose, args } => ops::edge(verbose, &args)?, + Command::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::Update { check, force } => cmd_update(check, force)?, Command::Status => cmd_status()?, @@ -418,6 +473,35 @@ fn cmd_start() -> Result<()> { Ok(()) } +/// 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 + // 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, + web, + }) +} + 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 8eb8502..1bce64f 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,76 @@ 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)?; + + // 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 +/// 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 @@ -705,3 +789,41 @@ 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. It starts it as the + // supervised unit, so a sign-in leaves the same display the boot would. + assert!(script.contains(":99 -screen 0")); + assert!(script.contains("systemd-run --user --unit=intune-xvfb")); + + 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 8a79ba6..a666121 100644 --- a/src/provision.rs +++ b/src/provision.rs @@ -156,27 +156,24 @@ done"#, ) } -/// The shell script the running container's root runs (via `setns`) once per -/// boot to recreate the session a PAM login would, so Intune works end to end: -/// the per-user systemd manager (for `XDG_RUNTIME_DIR` + D-Bus session bus), -/// an unlocked gnome-keyring with a created `login` collection, the Intune -/// **compliance agent timer** (without it the device never reports compliant), -/// no stale broker lock left in `/dev/shm`, and a broker restart so it re-reads -/// the keyring. Mirrors the nspawn session. +/// 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 display and the keyring daemon run as **transient user units**, never as -/// bare background processes. The `setns` exec that runs this script is torn down -/// with its cgroup, and every child of it dies at that moment — minutes after a -/// boot that reported success. -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). - // `Restart=always` matters as much as the unit does: a dead :99 makes the - // broker exit at activation, and every token call then answers `NoReply` — - // which reads exactly like a locked keyring. - let display_block = if headless { - r#"_xvfb=$(command -v Xvfb 2>/dev/null) +/// 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. +/// +/// It runs as a transient user unit with `Restart=always` rather than as a bare +/// background process, because a child of the `setns` exec dies with that exec's +/// cgroup. Run a second time — which is what a sign-in does on its way out — +/// `is-active` makes the unit a no-op and only the environment is published again. +pub const BROKER_DISPLAY_SCRIPT: &str = r#"_xvfb=$(command -v Xvfb 2>/dev/null) if [ -n "$_xvfb" ]; then if ! systemctl --user is-active --quiet intune-xvfb.service 2>/dev/null; then systemctl --user reset-failed intune-xvfb.service >/dev/null 2>&1 || true @@ -191,10 +188,27 @@ if [ -n "$_xvfb" ]; then 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 { - "" - }; +"#; + +/// The shell script the running container's root runs (via `setns`) once per +/// boot to recreate the session a PAM login would, so Intune works end to end: +/// the per-user systemd manager (for `XDG_RUNTIME_DIR` + D-Bus session bus), +/// an unlocked gnome-keyring with a created `login` collection, the Intune +/// **compliance agent timer** (without it the device never reports compliant), +/// no stale broker lock left in `/dev/shm`, and a broker restart so it re-reads +/// the keyring. Mirrors the nspawn session. +/// +/// The display and the keyring daemon run as **transient user units**, never as +/// bare background processes. The `setns` exec that runs this script is torn down +/// with its cgroup, and every child of it dies at that moment — minutes after a +/// boot that reported success. +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 the container's own and publish DISPLAY into the + // user D-Bus activation environment, where the on-demand broker inherits it. + // The block is a const because the two sign-in viewers run it again on their + // way out, to take the broker off the private display they created. + 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 diff --git a/src/termview.rs b/src/termview.rs new file mode 100644 index 0000000..e2d2346 --- /dev/null +++ b/src/termview.rs @@ -0,0 +1,1032 @@ +//! 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] + } +} + +/// 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 +/// 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 { + /// 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); + 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, + Space, + 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, + // Space activates a focused GTK button, which Enter does not. + Key::Space => 0x0020, + 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::*; + + /// 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 + // 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/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); + } +} diff --git a/src/xscreen.rs b/src/xscreen.rs new file mode 100644 index 0000000..567dbd0 --- /dev/null +++ b/src/xscreen.rs @@ -0,0 +1,518 @@ +//! 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).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 { + 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) + } + + /// 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 { + 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..10b7b7d --- /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(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 + // 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); + } +}