diff --git a/README.md b/README.md index b549571..d463576 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,32 @@ 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. +#### Or share the screen and drive Intune yourself + +`login` signs in *for* you: it presses Sign in, types the address and the password, +and closes what it opened. When you would rather see Intune and do every step +yourself, share the screen instead: + +```sh +intune-container screen # opens Intune, streams it, drives nothing +``` + +It starts the Intune portal so the first screen is there without a second command, +then touches nothing: every window the container opens — the portal, the identity +broker's Authentication dialog, an Edge window — appears as it is, and your keyboard +and mouse go straight to it. `--bind`, `--port` and `--geometry` work as they do for +`login --web`, and the link carries the same per-session token. + +It streams the box around the windows rather than the whole screen, so a 478×628 +portal fills the page instead of sitting in the corner of a black 1280×800 desktop. +A screen with nothing on it yet says so on the picture. + +Run it twice and the second one shares the screen the first already opened, rather +than making an empty one: `intune-portal` is a single instance, so a command that +insisted on its own display would stream black pixels while the portal drew +somewhere else. A share that finds a screen leaves it running when it closes — only +a share that opened one closes it. + 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 @@ -181,6 +207,9 @@ shells out, and neither needs elevated privilege. - [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] **Screen share** (`screen`) — Intune opened for you and streamed to a + browser, driving nothing: every window as it is, cropped to the windows, and a + second run shares the screen the first one opened. - [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 e3d1ec8..5e274d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,10 @@ pub mod xvfb; /// Serving that same display to a browser, over the tailnet (`login --web`). pub mod webview; +/// Sharing the container's screen to a browser, with no sign-in driving +/// (`screen`). +pub mod screen; + /// Pure-Rust OCI image pull + layer extraction (no docker/podman). pub mod oci; diff --git a/src/login.rs b/src/login.rs index 7b1b870..112b64d 100644 --- a/src/login.rs +++ b/src/login.rs @@ -396,7 +396,10 @@ extern "C" fn on_signal(_: nix::libc::c_int) { /// 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() { +/// +/// [`crate::screen`] shares it: a screen share is run over the same SSH +/// connection, and it leaves the same three things behind. +pub fn catch_hangups() { use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; let action = SigAction::new( SigHandler::Handler(on_signal), diff --git a/src/main.rs b/src/main.rs index 5c7e085..ac5a735 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use std::io::IsTerminal; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use intune_container::{autostart, config, doctor, login, ops, webview}; +use intune_container::{autostart, config, doctor, login, ops, screen, webview}; #[derive(Parser)] #[command( @@ -118,6 +118,27 @@ enum Command { #[arg(long, value_name = "N", requires = "web")] port: Option, }, + /// Share the container's screen in a browser: Intune opens, and you drive + /// every step yourself + Screen { + /// Size of the screen, when this command has to create one. A screen it + /// finds keeps the size it has. + #[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, + + /// Address to serve on (default: the tailnet address, or 127.0.0.1 when + /// there is none) + #[arg(long, value_name = "ADDR")] + bind: Option, + + /// Port to serve on + #[arg(long, value_name = "N")] + port: Option, + }, /// Stop the container Stop, /// Re-pull the container image when the registry has a newer build @@ -287,6 +308,12 @@ fn main() -> Result<()> { display, web.then(|| webview::Options::resolved(bind, port)), )?, + Command::Screen { + geometry, + display, + bind, + port, + } => cmd_screen(&geometry, display, bind, port)?, Command::Stop => ops::stop()?, Command::Update { check, force } => cmd_update(check, force)?, Command::Status => cmd_status()?, @@ -502,6 +529,23 @@ fn cmd_login( }) } +/// Share the container's screen. No credentials are asked for: this command drives +/// nothing, so there is nothing to type them into. +fn cmd_screen( + geometry: &str, + display: Option, + bind: Option, + port: Option, +) -> Result<()> { + let (width, height) = login::parse_geometry(geometry)?; + screen::run(screen::Options { + width, + height, + display, + web: webview::Options::resolved(bind, port), + }) +} + 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 1bce64f..647daaa 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -15,6 +15,7 @@ //! the GUI can subscribe to it. use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use serde::Serialize; @@ -240,9 +241,36 @@ pub fn portal_stop() -> Result<()> { // 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"); + wait_for_portal_exit(); Ok(()) } +/// How long to wait for a portal that was asked to close. +const PORTAL_EXIT_WAIT: Duration = Duration::from_secs(10); + +/// Wait for the portal to go, and say so when it will not. +/// +/// [`portal_stop`] waits rather than returning at the `pkill`, because every caller +/// does something next that a portal still running would change: +/// +/// * [`portal_finish`] leaves the display attached when a GUI app is up, so a +/// teardown that raced the exit left the container forwarding a display with +/// nothing on it — indistinguishable, to the next run and to `status`, from a +/// sign-in in progress; and +/// * a screen share starts a portal next, and `intune-portal` is a single +/// instance: a launch that arrives before the old one is gone hands its request +/// to the process on the way out, and the new display stays empty. +fn wait_for_portal_exit() { + let deadline = Instant::now() + PORTAL_EXIT_WAIT; + while Instant::now() < deadline { + if !portal_is_running() { + return; + } + std::thread::sleep(Duration::from_millis(200)); + } + warn!("the Intune portal is still running {PORTAL_EXIT_WAIT:?} after it was asked to close"); +} + /// 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")?; @@ -826,4 +854,37 @@ mod tests { "portal_start must restore the broker's display before it returns" ); } + + /// `portal_stop` has to outlive the portal it asks to close. + /// + /// A `pkill` that returns straight away leaves two callers wrong: the detach + /// that follows it sees a GUI app running and skips, so the container keeps + /// forwarding a display with nothing on it; and a screen share that starts a + /// portal next hands its request to the instance on the way out, and streams an + /// empty display. Neither shows up in a unit test — both need a container — so + /// the guard is on the code. + #[test] + fn closing_the_portal_waits_for_it_to_go() { + let source = include_str!("ops.rs"); + let start = source + .split("pub fn portal_stop") + .nth(1) + .expect("portal_stop exists"); + let body = start.split("\n}\n").next().unwrap_or(start); + assert!( + body.contains("wait_for_portal_exit"), + "portal_stop must wait for the exit, not just ask for it" + ); + // And the wait has to end: a portal that ignores TERM must not hang the + // command that asked it to close. + let wait = source + .split("fn wait_for_portal_exit") + .nth(1) + .expect("the wait exists"); + let wait = wait.split("\n}\n").next().unwrap_or(wait); + assert!( + wait.contains("deadline"), + "the wait must be bounded by a deadline" + ); + } } diff --git a/src/screen.rs b/src/screen.rs new file mode 100644 index 0000000..358edc4 --- /dev/null +++ b/src/screen.rs @@ -0,0 +1,229 @@ +//! `intune-container screen` — the container's screen, shared to a browser. +//! +//! WHY THIS IS NOT `login --web`. That command signs in: it owns a private +//! display, launches the portal, presses Sign in and types the address and the +//! password ([`crate::login::automate`] does the driving), and it closes +//! everything it opened. This one shows. It starts the portal so the first Intune +//! screen is there without a second command, and from that point it touches +//! nothing: every window the container opens — the portal, the identity broker's +//! Authentication dialog, an Edge window — is drawn as it is, and the reader +//! drives it with their own keyboard and mouse. +//! +//! WHY IT LOOKS FOR A DISPLAY BEFORE IT MAKES ONE. `intune-portal` is a single +//! instance. A second launch hands its request to the running one and exits, so a +//! command that always makes a fresh display gets a display with nothing on it: +//! the portal is still drawing on the display of whatever session started it. The +//! browser then streams a black screen, and nothing in the picture says why. So +//! this looks for a display in this tool's range that already has a window and +//! streams that one instead. +//! +//! WHAT IT LEAVES BEHIND. A display it created, with the portal it started, is +//! torn down the way `login` tears its own down. A display it found is left +//! running, because the session that made it is the one that owns it. + +use anyhow::{Context, Result}; +use tracing::debug; + +use crate::xscreen::XScreen; +use crate::xvfb::{self, Xvfb}; +use crate::{display, login, ops, webview}; + +/// How the reader asked for the share to look. +pub struct Options { + /// The size of the display, when this command has to create one. A display it + /// finds keeps the size it already has. + pub width: u32, + pub height: u32, + /// Force a display number instead of taking the first free one. + pub display: Option, + /// Where to serve it. + pub web: webview::Options, +} + +impl Default for Options { + fn default() -> Self { + Self { + width: 1280, + height: 800, + display: None, + web: webview::Options::resolved(None, None), + } + } +} + +/// Share the container's screen until the reader closes the page or the last +/// window goes. +pub fn run(options: Options) -> Result<()> { + login::catch_hangups(); + + let stage = open(&options)?; + let screen = XScreen::connect(stage.display_name()) + .with_context(|| format!("cannot read the display {}", stage.display_name()))?; + + let session = webview::serve(&screen, &options.web)?; + stage.close(session.portal_gone); + Ok(()) +} + +/// The display this session streams, and what it had to create to get one. +enum Stage { + /// A display that already had a window on it. Another session owns it, so + /// nothing on it is this command's to stop. + Found(String), + /// A display this command created, and the portal it started on it. + Made { + // Dropping this kills the X server, so it is held for the whole session + // even though nothing reads it. + _xvfb: Xvfb, + name: String, + }, +} + +impl Stage { + fn display_name(&self) -> &str { + match self { + Stage::Found(name) => name, + Stage::Made { name, .. } => name, + } + } + + /// Put back what this command changed, and only that. + fn close(self, portal_gone: bool) { + match self { + Stage::Found(name) => { + eprintln!(); + eprintln!("The share is closed. The screen {name} stays, and so does everything"); + eprintln!("on it: this command did not start it."); + } + Stage::Made { name, .. } => { + eprintln!(); + if portal_gone { + eprintln!("The last window on {name} closed."); + } else { + eprintln!("Closing the Intune portal on {name}."); + 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:#}"); + } + } + } + } +} + +/// Get a display with Intune on it: the one that already has it, or a new one with +/// a portal started on it. +fn open(options: &Options) -> Result { + if let Some(name) = drawing_display(xvfb::display_taken, |name| { + XScreen::connect(name) + .map(|screen| screen.has_window()) + .unwrap_or(false) + }) { + eprintln!("Sharing the screen the container is already drawing on: {name}"); + eprintln!("This command started nothing on it, so it will close nothing."); + return Ok(Stage::Found(name)); + } + + // A portal with no display to draw on is a portal nobody can reach: its + // display went with the session that made it. A second launch would hand the + // request to it and exit, leaving the new display empty — which is the black + // screen this whole search exists to prevent. So it goes first. + if ops::portal_is_running() { + eprintln!("A portal is running with no screen left to draw on. Closing it, so the"); + eprintln!("one this command starts is the one you see."); + // `portal_stop` waits for the exit, which is what makes the launch below + // reach a container with no instance of a single-instance application. + ops::portal_stop().context("could not close the portal that has no screen")?; + } + + let xvfb = Xvfb::start(options.width, options.height, options.display)?; + let name = xvfb.display(); + debug!(display = %name, "made a screen to share"); + + // The container reaches it the way `edge` and `enroll` reach the user's: the + // socket directory is bound in and DISPLAY names this one. No cookie — a + // display made a moment ago has no access control, and handing the container + // the user's Xauthority would grant more than this needs. + let info = display::DisplayInfo { + wayland_socket: None, + x11_display: Some(name.clone()), + xauthority: None, + has_abstract_x11: false, + }; + ops::portal_start(&info).context("could not start the Intune portal")?; + + Ok(Stage::Made { _xvfb: xvfb, name }) +} + +/// The first display in this tool's range that exists and has a window on it. +/// +/// Both questions are parameters so a test can decide what exists and what is +/// drawn without an X server and without touching `/tmp`. `exists` is asked first +/// because it is a `stat` and the other one is a connection. +fn drawing_display( + exists: impl Fn(u32) -> bool, + has_window: impl Fn(&str) -> bool, +) -> Option { + xvfb::private_displays() + .filter(|number| exists(*number)) + .map(|number| format!(":{number}")) + .find(|name| has_window(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_display_that_draws_is_the_one_taken() { + // :77 and :78 both exist, only :78 has a window: a session was left behind + // on :77 with nothing on it, and the portal is on :78. This is the case + // that used to hand the reader a black screen. + let found = drawing_display(|n| n == 77 || n == 78, |name| name == ":78"); + assert_eq!(found.as_deref(), Some(":78")); + } + + #[test] + fn a_display_that_does_not_exist_is_never_connected_to() { + // The `stat` comes first: a connection attempt per free number would be 22 + // of them on every run. + let asked = std::cell::RefCell::new(Vec::new()); + drawing_display( + |n| n == 80, + |name| { + asked.borrow_mut().push(name.to_string()); + false + }, + ); + assert_eq!(asked.into_inner(), vec![":80".to_string()]); + } + + #[test] + fn no_window_anywhere_means_no_display_to_share() { + // The caller then makes its own, which is the first-run path. + assert_eq!(drawing_display(|_| true, |_| false), None); + } + + #[test] + fn only_the_private_range_is_offered() { + // The user's own session (:0) and the container's compliance agent (:99) + // are never candidates, however much they are drawing. + let seen = std::cell::RefCell::new(Vec::new()); + drawing_display( + |_| true, + |name| { + seen.borrow_mut().push(name.to_string()); + false + }, + ); + let seen = seen.into_inner(); + assert!(!seen.contains(&":0".to_string())); + assert!(!seen.contains(&":99".to_string())); + for name in &seen { + let number: u32 = name.trim_start_matches(':').parse().expect("a number"); + assert!((77..=98).contains(&number), "{name} is outside the range"); + } + } +} diff --git a/src/termview.rs b/src/termview.rs index e2d2346..da745f9 100644 --- a/src/termview.rs +++ b/src/termview.rs @@ -42,6 +42,35 @@ impl Frame { Self { width, height, rgb } } + /// The part of the frame inside `box_`, clipped to what the frame holds. + /// + /// The browser viewer sends this rather than the whole display: the window is a + /// third of the screen it sits on, and the rest is desktop nobody drew on. It + /// costs a copy of the box, which is smaller than the frame it copies from. + pub fn crop(&self, box_: Bounds) -> Frame { + let x0 = box_.x.min(self.width); + let y0 = box_.y.min(self.height); + let width = box_.width.min(self.width - x0); + let height = box_.height.min(self.height - y0); + if width == 0 || height == 0 { + return Frame { + width: 0, + height: 0, + rgb: Vec::new(), + }; + } + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for row in 0..height { + let start = (((y0 + row) * self.width + x0) * 3) as usize; + let len = (width * 3) as usize; + match self.rgb.get(start..start + len) { + Some(bytes) => rgb.extend_from_slice(bytes), + None => rgb.extend(std::iter::repeat_n(0u8, len)), + } + } + Frame { width, height, rgb } + } + fn pixel(&self, x: u32, y: u32) -> [u8; 3] { if x >= self.width || y >= self.height { return [0, 0, 0]; @@ -696,6 +725,51 @@ mod tests { ); } + #[test] + fn a_crop_holds_the_pixels_of_its_box() { + // A 4×4 frame with one white pixel at 2,1. Cropping to the 2×2 box at 2,1 + // has to put that pixel first and nothing else with it. + let mut frame = Frame::flat(4, 4, [0, 0, 0]); + // Row 1, column 2, three bytes per pixel. + let at = (4 + 2) * 3; + frame.rgb[at] = 255; + frame.rgb[at + 1] = 255; + frame.rgb[at + 2] = 255; + let cut = frame.crop(Bounds { + x: 2, + y: 1, + width: 2, + height: 2, + }); + assert_eq!((cut.width, cut.height), (2, 2)); + assert_eq!(cut.rgb.len(), 2 * 2 * 3); + assert_eq!(&cut.rgb[0..3], &[255, 255, 255]); + assert!(cut.rgb[3..].iter().all(|b| *b == 0)); + } + + #[test] + fn a_crop_is_clipped_to_the_frame_it_cuts_from() { + // A window may hang off the edge of the display. Reading past the last row + // would take another window's pixels, or none at all. + let frame = Frame::flat(8, 8, [7, 7, 7]); + let cut = frame.crop(Bounds { + x: 6, + y: 6, + width: 100, + height: 100, + }); + assert_eq!((cut.width, cut.height), (2, 2)); + assert_eq!(cut.rgb.len(), 2 * 2 * 3); + // A box entirely outside is empty rather than a panic. + let none = frame.crop(Bounds { + x: 8, + y: 8, + width: 4, + height: 4, + }); + assert_eq!((none.width, none.height), (0, 0)); + } + #[test] fn an_empty_display_reports_the_whole_frame() { // Otherwise the viewer would have to special-case a display with no window diff --git a/src/webview.rs b/src/webview.rs index 6670c9a..4f727de 100644 --- a/src/webview.rs +++ b/src/webview.rs @@ -131,6 +131,7 @@ fn serve_on(screen: &XScreen, listener: TcpListener, token: String) -> Result { seq: u32, current: Option>, captured: Instant, + /// Where on the display the top-left of the last frame sat. A click comes back + /// in the frame's coordinates and has to be put back into the display's. + origin: (u32, u32), /// The reader pressed Finish. finished: bool, /// The portal has gone; the page is told so it can stop polling. @@ -266,6 +270,21 @@ impl Server<'_> { return respond(stream, "204 No Content", "text/plain", &[], b""); } if !constant_time_eq(request.token.as_bytes(), self.token.as_bytes()) { + // A refused page load is almost always a link from an earlier session: + // the token is in the URL, so the browser keeps the old one in its + // history and offers it back in the address bar. That reader gets a + // page that says so. The paths the page itself calls keep the plain + // body, because the script reads the status and never the text. + if request.method == "GET" && request.path == "/" { + let page = refused_page(); + return respond( + stream, + "403 Forbidden", + "text/html; charset=utf-8", + &[], + page.as_bytes(), + ); + } return respond(stream, "403 Forbidden", "text/plain", &[], b"wrong token\n"); } match (request.method.as_str(), request.path.as_str()) { @@ -356,14 +375,33 @@ impl Server<'_> { ) } - /// The display as it is now, re-reading it only when the last read is stale. + /// The windows on the display as they are now, re-reading them only when the + /// last read is stale. + /// + /// What comes back is the box around the windows, not the display: a portal is + /// 478×628 on a 1280×800 screen, and a page that showed the screen would be + /// two thirds black. The box is remembered in [`Server::origin`], because a + /// click arrives in the coordinates of what was sent. 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()?)); + let frame = self.screen.capture()?; + let frame = match self.screen.window_bounds()? { + Some(box_) => { + self.origin = (box_.x, box_.y); + frame.crop(box_) + } + // Nothing is drawn yet. The whole display is the honest answer, and + // the page says on it that there is no window. + None => { + self.origin = (0, 0); + frame + } + }; + self.current = Some(Arc::new(frame)); self.captured = Instant::now(); } Ok(self.current.clone().expect("just captured")) @@ -376,7 +414,7 @@ impl Server<'_> { for event in events { match event { Event::Text { v } => { - self.screen.place_and_focus()?; + self.focus(); let refused = self.screen.type_text(&v)?; if !refused.is_empty() { debug!("this display's keyboard cannot type {refused:?}"); @@ -385,21 +423,49 @@ impl Server<'_> { 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.focus(); self.screen.press(key)?; } Event::Mouse { x, y, down } => { - self.screen.click(x, y, down)?; + // The page clicks in the picture it was sent, which is the box + // around the windows. XTEST wants the display. + let (ox, oy) = self.origin; + self.screen + .click(on_display(x, ox), on_display(y, oy), 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()?; + self.focus(); } } } } Ok(()) } + + /// Give the keyboard to the window, and carry on if that cannot be done. + /// + /// Placing and focusing is best-effort by nature: there is no window manager on + /// this display, and the call races the window it names — a dialog that closes + /// between the capture and the keystroke makes the focus request name a window + /// the server has already forgotten. Refusing the keystroke for that would be + /// the wrong trade: XTEST sends it to whatever holds the focus, which is the + /// window the reader was typing into a moment ago. + fn focus(&self) { + if let Err(e) = self.screen.place_and_focus() { + debug!("cannot place or focus the window: {e:#}"); + } + } +} + +/// One coordinate of a click, moved from the picture the page holds to the display +/// the picture was cut from. +/// +/// Saturating rather than wrapping: a click far outside a small window is a bug in +/// the page or a stale frame, and a wrapped coordinate would land at the opposite +/// edge — on whatever button happens to be there. +fn on_display(point: i16, origin: u32) -> i16 { + point.saturating_add(origin.min(i16::MAX as u32) as i16) } /// What the page sends back. Text and keys are separate because a printable @@ -689,10 +755,16 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } +/// The token's width in bytes, and the same width in the hex the link carries. The +/// refused page names the count, because a truncated copy is one of the two ways a +/// reader arrives at it. +const TOKEN_BYTES: usize = 16; +const TOKEN_HEX: usize = TOKEN_BYTES * 2; + /// 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]; + let mut bytes = [0u8; TOKEN_BYTES]; std::fs::File::open("/dev/urandom") .and_then(|mut f| f.read_exact(&mut bytes)) .context("cannot read /dev/urandom for a session token")?; @@ -744,10 +816,57 @@ fn print_url(options: &Options, token: &str) { } 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!("refused, and that includes the link from an earlier sign-in: paste this one"); + eprintln!("whole, rather than letting the address bar complete an older one."); + eprintln!("Press Ctrl+C here, or Finish in the page, to close the window."); eprintln!(); } +/// The answer to a page load that carries no token, or one from a session that has +/// ended. +/// +/// A bare `wrong token` is true and useless: the reader cannot tell it from a +/// broken viewer, and the thing they have to do — take the link this run printed — +/// is not in it. The port is open to whoever reads this, so the page says nothing +/// that the open port does not already say. +fn refused_page() -> String { + format!( + r#" + + + + +Intune sign-in — the link is not for this session + + + +
+

This link is not for the session that is running.

+

The viewer mints a token for every login --web, and it refuses a +request that carries another one. A link from an earlier sign-in stops working when +that sign-in ends.

+

Go to the terminal that runs login --web. It printed one link for +this session. The address is the one you are on; only the {hex} characters after +?k= are different. Paste that link whole.

+

The address bar completes an older link from the history, so paste the new one +rather than accepting the completion.

+
+ + +"#, + hex = TOKEN_HEX, + ) +} + /// 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 @@ -765,9 +884,16 @@ fn page(width: u32, height: u32) -> String { html, body {{ margin: 0; height: 100%; background: #12151a; color: #d7dae0; font: 13px/1.5 ui-sans-serif, system-ui, sans-serif; }} body {{ display: flex; flex-direction: column; }} - #frame {{ flex: 1; display: grid; place-items: center; overflow: auto; outline: none; }} + #frame {{ flex: 1; display: grid; place-items: center; overflow: auto; outline: none; + position: relative; }} canvas {{ max-width: 100%; max-height: 100%; background: #000; box-shadow: 0 0 0 1px #2a2f37; }} + /* Over the canvas, and never in the way of a click: an empty display is black, + and a black rectangle that says nothing reads as a broken viewer. */ + #hint {{ position: absolute; display: none; max-width: 26em; text-align: center; + pointer-events: none; color: #9aa1ad; background: rgba(18, 21, 26, 0.88); + border: 1px solid #2a2f37; border-radius: 6px; padding: 14px 18px; }} + #hint b {{ display: block; color: #d7dae0; font-size: 15px; margin-bottom: 6px; }} #bar {{ display: flex; gap: 16px; align-items: center; padding: 8px 12px; background: #1b1f26; border-top: 1px solid #2a2f37; }} #state {{ flex: 1; color: #9aa1ad; }} @@ -778,7 +904,10 @@ fn page(width: u32, height: u32) -> String { -
+
+
Nothing is drawing on this screen yet. + The Intune portal takes up to 30 seconds to open its first window. The picture + arrives on its own — there is nothing to reload.
connecting… click the page, then type — Ctrl+V pastes @@ -790,6 +919,7 @@ const canvas = document.getElementById('screen'); const ctx = canvas.getContext('2d', {{ alpha: false }}); const state = document.getElementById('state'); const frame = document.getElementById('frame'); +const hint = document.getElementById('hint'); const KEYS = {{ Enter: 'Return', Tab: 'Tab', Backspace: 'BackSpace', Delete: 'Delete', Escape: 'Escape', ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', @@ -816,8 +946,16 @@ function draw(view) {{ if (view.getUint32(0) !== 0x49435631) return false; // magic, seq, width, height, flags, count — then one rectangle per tile. seq = view.getUint32(4); + const width = view.getUint16(8), height = view.getUint16(10); const flags = view.getUint16(12); const count = view.getUint16(14); + // The picture is the box around the windows, so it changes size when a dialog + // opens beside the portal. Resizing the canvas clears it, and the tiles in this + // same answer are a whole frame — the server sends one whenever the size moves. + if (width && height && (canvas.width !== width || canvas.height !== height)) {{ + canvas.width = width; + canvas.height = height; + }} let at = 16; for (let n = 0; n < count; n++) {{ const x = view.getUint16(at), y = view.getUint16(at + 2); @@ -835,10 +973,14 @@ function draw(view) {{ }} if (flags & 2) {{ alive = false; + hint.style.display = 'none'; say('the sign-in window closed — you can close this tab'); }} else if (!(flags & 1)) {{ - say('waiting for the sign-in window…'); + // The picture is a black rectangle at this point, so the reason goes ON it. + hint.style.display = 'block'; + say('waiting for a window on the screen…'); }} else {{ + hint.style.display = 'none'; say('sign-in window · ' + canvas.width + '×' + canvas.height); }} return count > 0; @@ -1062,11 +1204,33 @@ mod tests { assert!(!constant_time_eq(b"", b"")); } + #[test] + fn a_refused_page_load_says_what_to_do_about_it() { + let page = refused_page(); + // The reader has to learn three things: the link is old, a new one exists, + // and the terminal holds it. A body that named none of them would leave + // them where `wrong token` did. + assert!(page.contains("")); + assert!(page.contains("login --web"), "the page names no command"); + assert!( + page.contains("?k="), + "the page does not show the link's shape" + ); + assert!( + page.contains(&TOKEN_HEX.to_string()), + "the page does not say how long the token is" + ); + assert!( + !page.contains("wrong token"), + "the page still holds the message it replaces" + ); + } + #[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_eq!(one.len(), TOKEN_HEX); assert!(one.chars().all(|c| c.is_ascii_hexdigit())); assert_ne!(one, two); } @@ -1135,6 +1299,52 @@ mod tests { assert!(!html.contains("{{")); } + #[test] + fn a_click_lands_where_the_window_is_and_not_where_the_picture_is() { + // The portal sits at 0,0, so most sessions add nothing at all. + assert_eq!(on_display(40, 0), 40); + // A broker dialog at 550,120 shifts every click in it by that much. + assert_eq!(on_display(40, 550), 590); + assert_eq!(on_display(0, 120), 120); + // A coordinate that would run past what XTEST can carry stops at the edge + // rather than wrapping onto the other side of the screen. + assert_eq!(on_display(i16::MAX, 10), i16::MAX); + assert_eq!(on_display(10, u32::MAX), i16::MAX); + } + + #[test] + fn the_page_follows_a_picture_that_changes_size() { + let html = page(1280, 800); + // The box grows when a dialog opens beside the portal, so the canvas has to + // take its size from the answer rather than from the first page load. + assert!( + html.contains("view.getUint16(8)"), + "the page ignores the width the server sends" + ); + assert!(html.contains("canvas.width = width")); + assert!(html.contains("canvas.height = height")); + } + + #[test] + fn an_empty_screen_carries_its_own_explanation() { + let html = page(1280, 800); + // A display with no window is black. The reader has to be told that on the + // picture, because the picture is the thing that looks broken. + assert!(html.contains(r#"id="hint""#), "the page has no overlay"); + assert!( + html.contains("Nothing is drawing on this screen yet"), + "the overlay says nothing" + ); + // It sits over the canvas, so it must never eat a click meant for a field. + assert!( + html.contains("pointer-events: none"), + "the overlay would swallow clicks" + ); + // Shown and hidden by the window flag, not by a timer. + assert!(html.contains("hint.style.display = 'block'")); + assert!(html.contains("hint.style.display = 'none'")); + } + #[test] fn input_is_read_as_the_page_sends_it() { let body = br#"[{"t":"text","v":"ab"},{"t":"key","v":"Return"}, @@ -1269,10 +1479,28 @@ mod tests { // 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"); + let (status, body) = get("/delta?seq=0", "not-the-token"); assert_eq!(status, 403, "a wrong token was served"); + assert_eq!( + body, b"wrong token\n", + "the script's own path got a page instead of a body" + ); let (status, _) = get("/delta?seq=0", ""); assert_eq!(status, 403, "a missing token was served"); + + // A page load with a stale token is the one refusal a person reads, so + // it answers with the page rather than with the two words. + let (status, stale) = get("/", "a-link-from-an-older-session"); + assert_eq!(status, 403, "a stale link was served the display"); + let stale = String::from_utf8_lossy(&stale); + assert!( + stale.contains(""), + "a stale link got no page" + ); + assert!( + stale.contains("login --web"), + "the refusal does not say where the new link is" + ); // 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"); @@ -1286,13 +1514,22 @@ mod tests { "the page has the wrong size" ); - // The first delta is the whole display: 800×600 is 25×19 tiles. + // The first delta is the whole picture, and the picture is the box + // around the windows rather than the display: an 80×24 xterm is a few + // hundred pixels on an 800×600 screen. let (status, body) = get("/delta?seq=0", token); assert_eq!(status, 200); assert_eq!(u32::from_be_bytes(body[0..4].try_into().unwrap()), MAGIC); let seq = u32::from_be_bytes(body[4..8].try_into().unwrap()); - let count = u16::from_be_bytes(body[14..16].try_into().unwrap()); - assert_eq!(count, 25 * 19, "the first delta was not the whole display"); + let width = u16::from_be_bytes(body[8..10].try_into().unwrap()) as u32; + let height = u16::from_be_bytes(body[10..12].try_into().unwrap()) as u32; + let count = u16::from_be_bytes(body[14..16].try_into().unwrap()) as u32; + assert!( + width > 0 && width < 800 && height > 0 && height < 600, + "the picture is the whole display ({width}x{height}), not the window" + ); + let tiles = width.div_ceil(TILE) * height.div_ceil(TILE); + assert_eq!(count, tiles, "the first delta was not the whole picture"); assert!(seq > 0, "the first delta carried no sequence number"); // xterm's background is white, so the picture must hold a white pixel: @@ -1310,26 +1547,37 @@ mod tests { assert!(white, "the window never appeared in a delta"); // Type into a window that belongs to xterm, not to us. + // + // Sent more than once if it has to be: the window being up is what this + // test can see, and a shell inside it being ready to read is not. On a + // loaded machine the first line can arrive before the prompt does, and + // that is xterm's timing rather than the input path. A line that never + // lands, after three passes over ten seconds each, is the path. 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; + for attempt in 1..=3 { + 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"); + for _ in 0..100 { + if std::path::Path::new(&expect).exists() { + landed = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if landed { break; } - std::thread::sleep(Duration::from_millis(100)); + eprintln!("pass {attempt}: the line has not landed — typing it again"); } let (status, _) = ask( diff --git a/src/xscreen.rs b/src/xscreen.rs index 567dbd0..d194a51 100644 --- a/src/xscreen.rs +++ b/src/xscreen.rs @@ -23,7 +23,7 @@ use x11rb::protocol::xproto::{ use x11rb::protocol::xtest::ConnectionExt as _; use x11rb::rust_connection::RustConnection; -use crate::termview::{Frame, Key}; +use crate::termview::{Bounds, Frame, Key}; /// X11 event types, as XTEST's `fake_input` wants them. const KEY_PRESS: u8 = 2; @@ -260,6 +260,93 @@ impl XScreen { matches!(self.topmost(), Ok(Some(_))) } + /// The box that holds every real window on the display, or `None` when there + /// is none. + /// + /// The browser viewer streams this box rather than the whole display. A portal + /// window is 478×628 on a 1280×800 screen, so two thirds of what it would + /// otherwise send is desktop that nobody put anything on — and a reader who + /// opens the page sees mostly black and reasonably concludes it is broken. + /// + /// It is the union and not the topmost window, because the identity broker + /// opens its Authentication dialog beside the portal rather than over it: a box + /// around one of them would cut the other in half. + /// + /// Read from the X geometry rather than from the pixels. The terminal viewer + /// finds its box by scanning for anything brighter than the desktop + /// ([`crate::termview::content_bounds`]), which is right for a half-block + /// picture but would crop a dark-themed window to the part of it that happens + /// to be light. + pub fn window_bounds(&self) -> Result> { + let mut box_: Option = None; + for window in self.real_windows()? { + let Ok(Ok(geometry)) = self.conn.get_geometry(window).map(|c| c.reply()) else { + continue; + }; + // A window may hang off the edge of the display; the box may not. + let x = geometry.x.max(0) as u32; + let y = geometry.y.max(0) as u32; + let right = (x + geometry.width as u32).min(self.width); + let bottom = (y + geometry.height as u32).min(self.height); + if right <= x || bottom <= y { + continue; + } + box_ = Some(match box_ { + None => Bounds { + x, + y, + width: right - x, + height: bottom - y, + }, + Some(so_far) => { + let x0 = so_far.x.min(x); + let y0 = so_far.y.min(y); + let x1 = (so_far.x + so_far.width).max(right); + let y1 = (so_far.y + so_far.height).max(bottom); + Bounds { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + } + } + }); + } + Ok(box_) + } + + /// Every mapped child of the root that is big enough to be a real window, + /// topmost first. Tooltips and the 1×1 helper windows GTK creates are skipped. + fn real_windows(&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")?; + let mut windows = Vec::new(); + // `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 { + windows.push(child); + } + } + Ok(windows) + } + /// 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> { diff --git a/src/xvfb.rs b/src/xvfb.rs index 10b7b7d..e9a2061 100644 --- a/src/xvfb.rs +++ b/src/xvfb.rs @@ -27,11 +27,43 @@ 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")?, - }; + if let Some(number) = number { + // A number the reader named is used or reported, never quietly swapped + // for another: they picked it to reach it. + return Self::start_on(width, height, number); + } + + // Picking a free number and starting a server on it are two steps, and + // between them another process can take the number: two screen shares can + // start in the same second, and the test suite starts two servers at once + // on purpose. + // + // The number itself says which failure this was, so no message is parsed: + // a display that is taken NOW, having been free a moment ago, was taken by + // somebody else, and the next number is worth trying. A failure that leaves + // the number free — Xvfb not installed, a refused geometry — is the + // reader's, and it is reported as it is rather than tried 22 more times. + let mut lost = Vec::new(); + loop { + let number = first_free_display(|n| display_taken(n) || lost.contains(&n)) + .with_context(|| { + format!("no free X display number between :{FIRST_DISPLAY} and :{LAST_DISPLAY}") + })?; + match Self::start_on(width, height, number) { + Err(e) if display_taken(number) => { + debug!( + number, + "another process took this display number first: {e:#}" + ); + lost.push(number); + } + other => return other, + } + } + } + + /// Start a server on one display number, with no search and no retry. + fn start_on(width: u32, height: u32, number: u32) -> Result { // 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. @@ -83,12 +115,12 @@ impl Xvfb { 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(); + let log = std::fs::read_to_string(log_path).unwrap_or_default(); + let said = said_why(&log); anyhow::bail!( "Xvfb exited immediately ({status}){}{}", if said.is_empty() { "" } else { ": " }, - said.lines().next().unwrap_or("") + said ); } if x11rb::connect(Some(&display)).is_ok() { @@ -120,17 +152,61 @@ impl Drop for Xvfb { } } +/// The last display number this tool creates one on. +/// +/// `:99` is deliberately outside it. The container's own compliance agent runs a +/// display there, and its socket lands in the `/tmp/.X11-unix` that is bound in — +/// so it looks like one of ours from the host, and a viewer that took it would +/// stream a 640×480 agent instead of a sign-in. +const LAST_DISPLAY: u32 = 98; + +/// Every display number this tool may have created, lowest first. +/// +/// The screen share walks it to find a display that a portal is already drawing +/// on, because starting a second, empty one is how a reader ends up streaming +/// black pixels. +pub fn private_displays() -> impl Iterator { + FIRST_DISPLAY..=LAST_DISPLAY +} + /// 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 { +pub 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`. +/// The first free display number in this tool's range, or `None`. fn first_free_display(taken: impl Fn(u32) -> bool) -> Option { - (FIRST_DISPLAY..=99).find(|n| !taken(*n)) + (FIRST_DISPLAY..=LAST_DISPLAY).find(|n| !taken(*n)) +} + +/// The line of Xvfb's output that says why it exited. +/// +/// Not the first line: Xvfb opens with the xkbcomp keymap warnings, which are +/// pages long on a current keyboard map and say nothing about the exit. The reason +/// is on an error line after them — `(EE) Server is already active for display 77` +/// — so that is what the message carries. +fn said_why(log: &str) -> String { + // A bare `(EE)` is a separator and carries nothing, so a line is judged by what + // is left of it once the marker is off. + let said: Vec<(bool, &str)> = log + .lines() + .map(|line| { + let line = line.trim(); + match line.strip_prefix("(EE)") { + Some(rest) => (true, rest.trim()), + None => (false, line), + } + }) + .filter(|(_, text)| !text.is_empty()) + .collect(); + said.iter() + .find(|(marked, _)| *marked) + .or_else(|| said.first()) + .map(|(_, text)| text.to_string()) + .unwrap_or_default() } #[cfg(test)] @@ -148,4 +224,45 @@ mod tests { // Better a clear failure than a second server fighting for :77. assert_eq!(first_free_display(|_| true), None); } + + #[test] + fn the_reason_is_read_past_the_keymap_warnings() { + // Real output from a display number that was already taken. Reporting the + // first line here gives "The XKEYBOARD keymap compiler (xkbcomp) reports:", + // which sends the reader after a keyboard problem they do not have. + let log = "The XKEYBOARD keymap compiler (xkbcomp) reports:\n\ + > Warning: Could not resolve keysym XF86CameraAccessEnable\n\ + > Warning: Could not resolve keysym XF86NextElement\n\ + Errors from xkbcomp are not fatal to the X server\n\ + (EE) \n\ + (EE) Server is already active for display 77\n\ + \tIf this server is no longer running, remove /tmp/.X77-lock\n"; + assert_eq!(said_why(log), "Server is already active for display 77"); + } + + #[test] + fn an_output_with_no_error_line_still_says_something() { + assert_eq!(said_why("Fatal server error:\n"), "Fatal server error:"); + assert_eq!(said_why(""), ""); + // A bare marker carries nothing, so it is not the line chosen. + assert_eq!(said_why("(EE)\nreal reason here"), "real reason here"); + } + + #[test] + fn a_lost_number_is_skipped_on_the_next_pass() { + // The search takes the next one rather than trying 77 for ever. + let lost = [77u32]; + let found = first_free_display(|n| lost.contains(&n)); + assert_eq!(found, Some(78)); + } + + #[test] + fn the_scan_stops_before_the_agents_display() { + // :99 is the container's compliance agent. A viewer that streamed it would + // show a 640×480 service, not a sign-in. + let numbers: Vec = private_displays().collect(); + assert_eq!(numbers.first(), Some(&77)); + assert_eq!(numbers.last(), Some(&98)); + assert!(!numbers.contains(&99)); + } }