feat(login): serve the sign-in window to a browser over the tailnet - #5
Closed
theophile-wallez wants to merge 6 commits into
Closed
theophile-wallez wants to merge 6 commits into
theophile-wallez wants to merge 6 commits into
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.
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.
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 |
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.
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. |
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.
Why
logindraws the display with half-block cells, which costs half the verticalresolution 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 7000Tailscale is the transport, not the display. The broker still renders the sign-in
in a WebKitGTK view on an X display, so
xvfb.rsandxscreen.rsare untouched andwebview.rsreplacestermview.rsalone. The private display, the automation, theportal 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_jsonandnixwere already dependencies;nixgains itsnetfeature forgetifaddrs):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 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 independentwithout a thread each. Sockets stay blocking for writes, with timeouts, rather than
growing a partial-write queue.
putImageDataper tile, and thekeyboard, mouse and paste posted back.
What protects it. The listener binds this host's tailnet address, read from
getifaddrsrather than from thetailscalecommand, and falls back to loopback withthe
ssh -Lline to reach it. Every request must carry a 128-bit token minted persession from
/dev/urandomand printed once — without it the socket is open to everyother device in the tailnet while a password is typed.
/favicon.icois the oneexception, 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.
a_browser_client_sees_the_display_and_types_into_it(ignored; needsXvfbandxterm) drives the whole server over TCP: a wrong token and a missing one arerefused, 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.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 warningsandcargo test --lockedare clean, andcargo build --release --lockedpasses.Two failures found while proving it against the browser, both now covered by tests:
widthandheightwere added, so it drew the height as flags and started thetiles one field early;
like a fault.
The first commit
365aaa7is formatting and lints only, and it is here because CI fails on the basebranch without it: the three files the terminal sign-in touched were committed
unformatted, and stable clippy has since grown
manual_div_ceilandredundant_closure, which both fire on them. No behaviour changes.