Skip to content

feat(login): serve the sign-in window to a browser over the tailnet - #5

Closed
theophile-wallez wants to merge 6 commits into
magicabdel:masterfrom
theophile-wallez:feat/login-web
Closed

theophile-wallez wants to merge 6 commits into
magicabdel:masterfrom
theophile-wallez:feat/login-web

Conversation

@theophile-wallez

@theophile-wallez theophile-wallez commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #4. The base has to be master because feat/terminal-login lives only
on my fork, so this PR carries #4's three commits as well. Only the last two are new:
365aaa7 (fmt + lints) and 65a2080 (the browser viewer). Merge #4 first and the
diff here reduces to those two.

Why

login draws the display with half-block cells, which costs half the vertical
resolution and all of the sub-pixel detail. The two-digit Authenticator number is the
one thing the reader came for, and it takes an F2 zoom to read. A browser draws
the same pixels at their own size, gives a real pointer instead of a cell coordinate,
and takes a Ctrl+V paste of a password out of a manager.

intune-container login --web            # prints one link, with a token for this session
intune-container login --web --bind 127.0.0.1 --port 7000

Tailscale is the transport, not the display. The broker still renders the sign-in
in a WebKitGTK view on an X display, so xvfb.rs and xscreen.rs are untouched and
webview.rs replaces termview.rs alone. The private display, the automation, the
portal and the teardown are shared with the terminal path — including the
broker-display rule from 2b26217, which holds for both viewers.

How

One thread, no async runtime, no WebSocket, no new crate (flate2, serde_json and
nix were already dependencies; nix gains its net feature for getifaddrs):

  • GET /delta — the 32-pixel tiles that changed since the sequence number the client
    holds, gzipped, so the browser inflates it and the page needs no decoder. A stale
    sequence number gets a whole frame, the only answer that is right after a missed
    update.
  • POST /input — one request per keystroke, so a key never waits for a frame.
  • poll(2) over the listener and the open sockets keeps those two independent
    without a thread each. Sockets stay blocking for writes, with timeouts, rather than
    growing a partial-write queue.
  • The page is one file with no build step: a canvas, putImageData per tile, and the
    keyboard, mouse and paste posted back.

What protects it. The listener binds this host's tailnet address, read from
getifaddrs rather than from the tailscale command, and falls back to loopback with
the ssh -L line to reach it. Every request must carry a 128-bit token minted per
session from /dev/urandom and printed once — without it the socket is open to every
other device in the tailnet while a password is typed. /favicon.ico is the one
exception, answered empty ahead of the check.

Testing

Unit — 15 new tests: the tile diff (first frame, no change, one pixel, a resize, a
clipped edge tile), the delta header against the offsets the page reads, the request
parse (query, header case, token in the URL vs the header), the token compare, the
tailnet range, and the event JSON.

End to end, twice.

  1. a_browser_client_sees_the_display_and_types_into_it (ignored; needs Xvfb and
    xterm) drives the whole server over TCP: a wrong token and a missing one are
    refused, the first delta is the whole display, a white pixel proves the tiles carry
    it, a typed line reaches a window that is not ours, and Finish ends the session.
    Run it with cargo test --lib -- --ignored --nocapture a_browser_client.
  2. The same server in a real Chromium: the xterm window drew sharp at 1024×768,
    keystrokes arrived in it, and Finish returned the session with the X server and the
    window cleaned up.

cargo fmt --all --check, cargo clippy --all-targets -- -D warnings and
cargo test --locked are clean, and cargo build --release --locked passes.

Two failures found while proving it against the browser, both now covered by tests:

  • the page read the flags and the tile count at the header offsets they had before
    width and height were added, so it drew the height as flags and started the
    tiles one field early;
  • the favicon request has no token, so the 403 left an error in the console that reads
    like a fault.

The first commit

365aaa7 is formatting and lints only, and it is here because CI fails on the base
branch without it: the three files the terminal sign-in touched were committed
unformatted, and stable clippy has since grown manual_div_ceil and
redundant_closure, which both fire on them. No behaviour changes.

…no VNC

A headless host still has to sign in interactively: the broker's Primary Refresh
Token expires, Entra answers interaction_required, and every token call fails
until a human completes a sign-in. There was no way to do that over SSH.

There is no text-only path to give, either. The broker refuses a device-code flow
on Linux ('AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform')
and renders the sign-in itself, in an embedded WebKitGTK view on an X display. So
`login` does not replace the window — it brings it to the terminal:

* xvfb.rs owns a private invisible display for the session;
* ops::portal_start runs the Intune portal on it (the enroll flow, split so the
  caller can drive the window between its steps);
* termview.rs draws the display with half-block cells, one repaint sending only
  the cells that changed;
* xscreen.rs reads the pixels with GetImage and sends keys and clicks with XTEST.

Four failures found while proving it against the real portal, each now covered:

* the display was handed over as soon as its socket appeared, but a socket is
  bound before the server listens — the first connection was refused;
* SIGKILL on teardown left the socket and lock behind, so the next run found a
  display number that nothing was using but nothing could use;
* with no window manager the portal mapped its dialog at 615,375, putting the
  Sign in button past the bottom edge — place_and_focus moves it to the origin;
* O_NONBLOCK on stdin also applies to stdout, so a frame larger than the tty
  buffer died with EAGAIN mid-session. Input is polled instead.

A hangup is now a normal exit, because this is run over SSH: the portal is
closed, the container returns to headless and the X server stops.
…legibly

`login` drew a window and left the reader to type into it, which is not what a
command is for — and what it drew was unreadable: 1280x800 of mostly black desktop
squeezed into a terminal turns 13-pixel text into two rows of colour.

Now the command does the work. It presses Sign in, and fills the address and the
password when asked to (--fill); a device that has signed in before has its account
remembered, so Entra goes straight to the Authenticator prompt and there is nothing
to fill — measured on the tenant, and the reason credentials are optional rather
than required.

There is no DOM behind that window (the broker renders it in its own WebKit view),
so every step is timed on the PICTURE: act, wait for it to move, wait for it to
settle. Three false positives had to die first, all the same mistake — treating any
pixel change as proof of a click:

* Enter alone does nothing: GTK gives that window no default button;
* Tab draws a focus ring, which moves ~3% of the pixels and read exactly like a
  press. The threshold for 'a new page' is now 15%;
* place_and_focus MOVES the window, so measuring the button before placing it
  clicked where the window used to be and then read its own move as success.

The button is clicked at a measured position (50%, 67% of the window) with the
keyboard as the fallback, not the other way round: a click is the only attempt whose
result can be verified.

What is drawn is now the window rather than the desktop (content_bounds), and the
viewer opens at ACTUAL SIZE rather than fitted — cropping is worth about a quarter,
measured, because the dialog is tall while a terminal has some hundred half-rows.
Fitting is F4.

Verified against the real tenant end to end: the portal opens, the button is
clicked, and Entra reaches 'Approve sign in' with a number-matching code on screen
and a request on the phone.
A terminal sign-in broke the whole account minutes after it ended, and the app
blamed the keyring.

The portal is launched through a login shell, and the container's session profile
publishes whatever DISPLAY it is given into the D-Bus activation environment. For
`edge` that display is the user's own and outlives the command; `login` creates an
EPHEMERAL one, so the identity broker — a GTK program, activated on demand — was
left with `DISPLAY=:77` after the Xvfb was gone. Every token call then answered
NoReply, which is the same signature as a locked keyring: the banner said the
keyring, `doctor` said all six checks pass, and the automatic repair restarted the
container three times before hitting its own rate limit. The real cause was one line
in the container's own journal: `cannot open display: :77`.

portal_start now puts the broker back on the container's :99 before it returns, and
starts that display rather than assuming it — a container booted with a display
forwarded has none. The portal keeps its own DISPLAY, which is the only process that
needs ours.

Verified live: a login run killed mid-session leaves getAccounts working and a Graph
token mintable, where before it left the account signed out.
The three files the terminal sign-in touched were committed unformatted, and
stable clippy has since grown two lints that fire on them. CI runs
`cargo fmt --all --check` and `cargo clippy --all-targets -- -D warnings`, so
the branch fails both gates before any new code is added.

Nothing here changes behaviour:

* rustfmt over ops.rs, termview.rs and xscreen.rs (assertions and one tuple
  that were hand-wrapped past the line the formatter picks);
* `manual_div_ceil` — the bits-per-pixel to bytes rounding is `div_ceil(8)`; and
* `redundant_closure` — `first_free_display` takes the function, not a closure
  around it.
The terminal viewer draws the display with half-block cells, which costs half the
vertical resolution: 13-pixel text survives that only when the reader zooms, and
the two-digit Authenticator number is the one thing they came for. `login --web`
serves the same display to a browser instead — at its own size, with a real
pointer, and with Ctrl+V for a password out of a manager.

Tailscale is the TRANSPORT, not the display. The broker still renders the sign-in
in a WebKitGTK view on an X display, so xvfb.rs and xscreen.rs are untouched and
webview.rs replaces termview.rs alone. The private display, the automation, the
portal and the teardown are shared with the terminal path, which is why the
broker-display rule from 2b26217 still holds for both.

The server is deliberately small — one thread, no async runtime, no WebSocket:

* `GET /delta` answers with the 32-pixel tiles that changed since the sequence
  number the client holds, gzipped, so the browser inflates it and the page needs
  no decoder. A stale sequence number gets a whole frame, which is the only
  answer that is right after a missed update;
* `POST /input` is one request per keystroke, so a key never waits for a frame;
* `poll(2)` over the listener and the open sockets keeps those independent
  without a thread each and without a partial-write queue. Sockets stay blocking
  for writes, with timeouts, rather than growing an output queue.

It binds this host's tailnet address, read from `getifaddrs` rather than from the
`tailscale` command, and falls back to loopback with the `ssh -L` line to reach
it. Every request must carry a 128-bit token minted per session, from
/dev/urandom and printed once: without it the socket would be open to every other
device in the tailnet while a password is typed. `/favicon.ico` is the one
exception, answered empty ahead of the check, because a browser asks for it with
no token and the console error reads like a fault.

Two failures found while proving it against a real browser:

* the page read the flags and the tile count at the header offsets they had
  before width and height were added, so it drew the height as flags and started
  the tiles one field early. The unit test now asserts both sides of that header;
* the favicon 403 above.

Verified end to end, twice. `a_browser_client_sees_the_display_and_types_into_it`
(ignored; needs Xvfb and xterm) drives the whole server over TCP: a wrong token
and a missing one are refused, the first delta is the whole display, a white pixel
proves the tiles carry it, a typed line reaches a window that is not ours, and
Finish ends the session. Then the same server in a real Chromium: the xterm window
drew sharp at 1024x768, four keystrokes arrived in it, and Finish returned the
session with the X server and the window cleaned up.
@theophile-wallez

Copy link
Copy Markdown
Contributor Author

@magicabdel this one is stacked on #4 — review that first, and the diff here drops to the two commits named at the top. The viewer is a single webview.rs: no new crate, no WebSocket, one thread. Verified against a real Chromium as well as the ignored end-to-end test.

The branch forked before v0.2.2, so both PRs were CONFLICTING and unmergeable.
One real conflict, in provision.rs, and it was a genuine collision of intent:

* master turned the container's :99 display into a transient user unit with
  `Restart=always`, because a child of the `setns` exec dies with that exec's
  cgroup — the display went minutes after a boot that reported success; while
* this branch had extracted the same block into `BROKER_DISPLAY_SCRIPT`, because a
  sign-in has to run it AGAIN on its way out to take the broker off the private
  display it created.

Both are kept: the const now holds master's supervised version, and
`runtime_setup_script` uses it for the headless block. Running it twice is safe by
construction — `is-active` makes the unit a no-op and only the environment is
published again, which is all the sign-in needs.

The ops.rs assertion moved with it: the script no longer spells `Xvfb :99`
literally (the binary is resolved into `$_xvfb` first), so it now asserts
`:99 -screen 0` and the `intune-xvfb` unit.

74 tests pass, fmt and clippy are clean, and the browser end-to-end test still
drives the real display after the merge.
@theophile-wallez

Copy link
Copy Markdown
Contributor Author

Folded into #4 — both viewers now live on that branch, together with the fmt/clippy fix and the merge of master at v0.2.2 that clears the conflict. Closing this one; nothing here is lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant