Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
46 changes: 45 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -118,6 +118,27 @@ enum Command {
#[arg(long, value_name = "N", requires = "web")]
port: Option<u16>,
},
/// 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<u32>,

/// Address to serve on (default: the tailnet address, or 127.0.0.1 when
/// there is none)
#[arg(long, value_name = "ADDR")]
bind: Option<std::net::IpAddr>,

/// Port to serve on
#[arg(long, value_name = "N")]
port: Option<u16>,
},
/// Stop the container
Stop,
/// Re-pull the container image when the registry has a newer build
Expand Down Expand Up @@ -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()?,
Expand Down Expand Up @@ -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<u32>,
bind: Option<std::net::IpAddr>,
port: Option<u16>,
) -> 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.");
Expand Down
61 changes: 61 additions & 0 deletions src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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"
);
}
}
Loading
Loading