Skip to content

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 into
magicabdel:masterfrom
theophile-wallez:feat/terminal-login
Aug 13, 2026
Merged

magicabdel merged 6 commits into
magicabdel:masterfrom
theophile-wallez:feat/terminal-login

Conversation

@theophile-wallez

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

Copy link
Copy Markdown
Contributor

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 human
completes 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 the
sign-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.

intune-container login              # drawn in your terminal; account remembered → straight to Authenticator
intune-container login --fill       # ask for address + password and type them
intune-container login --manual     # touch nothing, drive the window by hand
intune-container login --web        # serve it to a browser on your tailnet, at full resolution

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 the
    caller can drive the window between steps).
  • xscreen.rs — reads pixels with GetImage, sends keys and clicks with XTEST.
  • The command drives the sign-in itself: it presses Sign in and fills credentials
    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.
  • Teardown is one path for both viewers: the portal closes, the container returns to
    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. --web serves the same display to a browser at its own size, with
a 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 client
    holds, 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 a
    thread each, and without a partial-write 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.
    --bind and --port override both.
  • 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.

Testing

cargo fmt --all --check, cargo clippy --all-targets -- -D warnings,
cargo test --locked (74 tests) and cargo build --release --locked all 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
login run killed mid-session leaves getAccounts working and a Graph token mintable;
before 2b26217 it left the account signed out.

Against a real display (ignored tests; need Xvfb and xterm):

  • typing_reaches_a_window_on_a_real_display — the capture comes back with the right
    pixel 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

  • a display socket is bound before the server listens → the first connection was refused;
  • SIGKILL on teardown left socket and lock behind → the next run found an unusable
    display number;
  • with no window manager the dialog mapped at 615,375, putting Sign in past the
    bottom edge → place_and_focus moves it to the origin;
  • O_NONBLOCK on stdin also applies to stdout → a frame larger than the tty buffer died
    with EAGAIN; input is polled instead;
  • Enter alone does nothing (no GTK default button), and Tab's focus ring moved ~3% of
    pixels and read as a click → the "new page" threshold is 15%;
  • place_and_focus moves the window, so measuring the button before placing it clicked
    the old position → the click is measured after placement, keyboard as fallback;
  • the container's session profile publishes DISPLAY into the D-Bus activation
    environment, so a login session left the broker on DISPLAY=:77 after its Xvfb was
    gone. Every token call answered NoReply — the same signature as a locked keyring,
    which is what the banner blamed while doctor reported all six checks passing
    (2b26217);
  • the page read the flags and the tile count at the header offsets they had before
    width and height were added → it drew the height as flags. Both sides of that
    header are now asserted;
  • /favicon.ico carries no token, so the 403 left a console error that reads like a
    fault. 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 committed
    unformatted, and stable clippy has since grown manual_div_ceil and
    redundant_closure, which fire on them. CI failed on both gates before this.
  • 9ec38c3 — merge of master at v0.2.2, which the branch predated. One real
    conflict, in provision.rs, and a genuine collision of intent: master made the
    container's :99 a transient user unit with Restart=always (a child of the setns
    exec dies with that exec's cgroup), while this branch had extracted the same block
    into BROKER_DISPLAY_SCRIPT so a sign-in can run it again on the way out. Both are
    kept — 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.

…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.
@theophile-wallez

Copy link
Copy Markdown
Contributor Author

@magicabdel ready for your review when you have a moment. Three commits, all verified live against the real tenant — the last one (2b26217) is the important fix: without it a terminal sign-in leaves the broker on the ephemeral display and the account drops minutes later.

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.
@theophile-wallez theophile-wallez changed the title feat(login): sign in to Entra ID from the terminal, with no screen and no VNC feat(login): sign in to Entra ID with no screen — in the terminal, or in a browser over the tailnet Aug 12, 2026
@magicabdel
magicabdel self-requested a review August 13, 2026 09:13
@magicabdel

Copy link
Copy Markdown
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

@magicabdel
magicabdel merged commit 7931bb5 into magicabdel:master Aug 13, 2026
2 checks passed
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.

2 participants