feat(login): sign in to Entra ID with no screen — in the terminal, or in a browser over the tailnet - #4
Merged
magicabdel merged 6 commits intoAug 13, 2026
Conversation
…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.
Contributor
Author
|
@magicabdel ready for your review when you have a moment. Three commits, all verified live against the real tenant — the last one ( |
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.
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.
magicabdel
self-requested a review
August 13, 2026 09:13
Owner
|
niice, maybe we can add a background login tokio task that will run just before the primary token expires => ensures a fresh token is always present |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sign in to Entra ID on a host with no screen — in the terminal, or in a browser over your tailnet. One feature, two viewers over the same private display.
(This supersedes #5, which is closed: both viewers are here now.)
Why
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 humancompletes a sign-in. Over SSH there was no way to do that.
There is no text-only path either: the broker refuses a device-code flow on Linux
(
AcquireTokenWithDeviceCodeFlow is not implemented on Linux platform) and renders thesign-in itself, in an embedded WebKitGTK view on an X display. The window has to
exist — so this brings it to you instead of replacing it.
The shared half
xvfb.rs— a private invisible display, owned for the session.ops::portal_start— runs the Intune portal on it (the enroll flow, split so thecaller can drive the window between steps).
xscreen.rs— reads pixels withGetImage, sends keys and clicks with XTEST.when asked for them. There is no DOM behind that window, so every step is timed on
the picture — act, wait for movement, wait for it to settle.
headless, the X server stops — including on a dropped SSH connection.
Viewer 1 — the terminal (
termview.rs)Half-block cells, one repaint sending only the cells that changed. F1 help,
F2 zoom, F4 fit, Ctrl+arrows pan, Ctrl+Q quit. Needs a terminal with
24-bit colour and nothing else — no VNC server, no compositor, no X forwarding.
Viewer 2 — the browser (
webview.rs)Half-block cells cost half the vertical resolution, and 13-pixel text survives that
only when you zoom.
--webserves the same display to a browser at its own size, witha real pointer and Ctrl+V for a password out of a manager. One thread, no async
runtime, no WebSocket, no new crate:
GET /delta— the 32-pixel tiles that changed since the sequence number the clientholds, gzipped, so the browser inflates it and the page needs no decoder. A stale
sequence number gets a whole frame, the only right answer 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 independent without athread each, and without a partial-write queue.
getifaddrsrather than from thetailscalecommand, and falls back to loopback with thessh -Lline to reach it.--bindand--portoverride both./dev/urandomandprinted once. Without it the socket is open to every other device in the tailnet
while a password is typed.
Testing
cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,cargo test --locked(74 tests) andcargo build --release --lockedall pass.Against the real tenant. 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
loginrun killed mid-session leavesgetAccountsworking and a Graph token mintable;before
2b26217it left the account signed out.Against a real display (ignored tests; need
Xvfbandxterm):typing_reaches_a_window_on_a_real_display— the capture comes back with the rightpixel format, and a typed line reaches a window that is not ours.
a_browser_client_sees_the_display_and_types_into_it— drives the whole HTTP server: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 lands, and Finish ends the session.
Against a real browser. The same server in Chromium: the window drew sharp at
1024×768, keystrokes arrived in it, and Finish returned the session with the X server
and the window cleaned up.
Failure modes found while proving it, each now covered
SIGKILLon teardown left socket and lock behind → the next run found an unusabledisplay number;
bottom edge →
place_and_focusmoves it to the origin;O_NONBLOCKon stdin also applies to stdout → a frame larger than the tty buffer diedwith
EAGAIN; input is polled instead;pixels and read as a click → the "new page" threshold is 15%;
place_and_focusmoves the window, so measuring the button before placing it clickedthe old position → the click is measured after placement, keyboard as fallback;
DISPLAYinto the D-Bus activationenvironment, so a
loginsession left the broker onDISPLAY=:77after its Xvfb wasgone. Every token call answered
NoReply— the same signature as a locked keyring,which is what the banner blamed while
doctorreported all six checks passing(
2b26217);widthandheightwere added → it drew the height as flags. Both sides of thatheader are now asserted;
/favicon.icocarries no token, so the 403 left a console error that reads like afault. It is answered empty, ahead of the check.
Two commits that are not the feature
365aaa7— fmt and lints only. The three files this branch touched were committedunformatted, and stable clippy has since grown
manual_div_ceilandredundant_closure, which fire on them. CI failed on both gates before this.9ec38c3— merge ofmasterat v0.2.2, which the branch predated. One realconflict, in
provision.rs, and a genuine collision of intent:mastermade thecontainer's
:99a transient user unit withRestart=always(a child of thesetnsexec dies with that exec's cgroup), while this branch had extracted the same block
into
BROKER_DISPLAY_SCRIPTso a sign-in can run it again on the way out. Both arekept — the const holds the supervised version, and running it twice is a no-op by
construction (
is-active), which is all the sign-in needs.