diff --git a/docs/adr/0090-s16-watch-and-take-over-a-working-computer.md b/docs/adr/0090-s16-watch-and-take-over-a-working-computer.md index d8d0a8e8..e339bd91 100644 --- a/docs/adr/0090-s16-watch-and-take-over-a-working-computer.md +++ b/docs/adr/0090-s16-watch-and-take-over-a-working-computer.md @@ -1,5 +1,5 @@ --- -status: implemented for DESK16-01 (#410), first slice; deterministic evidence; guest desktop session, desktop-browser egress and live host evidence pending (#410 follow-up, #431) +status: implemented for DESK16-01 (#410), first slice; deterministic evidence; guest desktop session and desktop-browser egress delivered by ADR 0091 (#453 and slice 2b); live host evidence pending (#431) date: 2026-11-08 scope: DESK16-01 (#410); S16-AC05/AC07; FR-02/10/14/15/18/21/35/36/38/40; NFR-01/06/07/11/12/14 --- @@ -44,5 +44,5 @@ Option 3 keeps Rakazo's product concept (watch, take control with a lease, gener ## Pending (not delivered by this slice) -- Guest desktop session (X server on the virtual display, window manager, browser) and the desktop browser's egress: the VM proxy admits only per-session leases, so a desktop browser outside a work session needs a reviewed lease design before a trusted-site login can work in the VM. #442's remaining item depends on it. +- ~~Guest desktop session and the desktop browser's egress~~: delivered by [ADR 0091](0091-s16-working-computer-desktop-and-browser.md) (desktop lease in #453, desktop session in slice 2b). #442's remaining item can now build on it. - Live evidence on `jit-vm` (QEMU `screendump` and input on the real host, VM15-03 file permissions, restart into the desktop profile) belongs to LIVE16-01 (#431). diff --git a/docs/adr/0091-s16-working-computer-desktop-and-browser.md b/docs/adr/0091-s16-working-computer-desktop-and-browser.md index 57380264..7d401b13 100644 --- a/docs/adr/0091-s16-working-computer-desktop-and-browser.md +++ b/docs/adr/0091-s16-working-computer-desktop-and-browser.md @@ -1,5 +1,5 @@ --- -status: proposed for DESK16-01 (#410), second slice; for review before implementation +status: accepted for DESK16-01 (#410), second slice; implemented with deterministic evidence (desktop lease #453, desktop session slice 2b with #454); live host evidence pending (#431) date: 2026-11-09 scope: DESK16-01 (#410); AUTH16-01 (#442) step 5; S16-AC07/AC11; FR-10/14/15/35/38/40; NFR-07/14 --- @@ -65,7 +65,9 @@ while the owner holds control, or per-site approval). What Rakazo does, read fro 6. **Pinned browser.** Ubuntu 26.04 ships Chromium only as a snap, which needs snapd and store egress. Proposed: install a version-pinned Chromium build with a recorded SHA-256 for x86_64 and arm64 through the existing bounded provisioning lease, like the Docker install. The alternative is Firefox ESR from Mozilla's apt repository with - its signing key pinned. Champ chose the pinned Chromium build on 2026-11-11. + its signing key pinned. Champ chose the pinned Chromium build on 2026-11-11. Implemented as Chrome for Testing + (Google's version-pinned Chromium build, published for both linux64 and linux-arm64, no auto-updater) from + `storage.googleapis.com/chrome-for-testing-public`, version and per-architecture SHA-256 in `qemuDesktopBrowser`. Its own `deb.deps` list drives the runtime packages. ## Consequences diff --git a/docs/contracts/s15-working-vm-v1.md b/docs/contracts/s15-working-vm-v1.md index 9eddc46b..c336fcbb 100644 --- a/docs/contracts/s15-working-vm-v1.md +++ b/docs/contracts/s15-working-vm-v1.md @@ -20,7 +20,9 @@ Stop/revoke immediately fences further calls and late publication. Host SSH clos The external guest network allows only a protected HTTP/CONNECT registry proxy. Its default exact hosts are exported as `defaultVmRegistryHosts`; owner settings can narrow/replace them at a fresh proxy incarnation. Public DNS answers are checked and the actual socket pins the checked address; private/control/metadata/IP-literal destinations are refused. Connections/bytes/time are finite, lease revocation closes active connections, and logs contain only session/host/byte metadata. **This is destination control, not content-level disclosure prevention.** Approved registries see request metadata and may receive workspace content; TLS cannot verify release-only/read-only semantics or block every publication at an allowed domain. Proxy attribution is cooperative against shared guest root. New destinations require policy; the exact HTTPS+SHA-256 bounded artifact path injects no credential and follows no redirect. -**Desktop browser lease (DESK16-01, ADR 0091 decisions 2, 3 and 5).** A local VM started with the explicit `desktop` opt-in gets one standing proxy lease, `desktop__`, issued by the same proxy after every boot and again when a restarted controller re-attaches a VM that survived (the restarted proxy holds no lease). It reaches the public web under the same rules as work sessions (public DNS name, 443 CONNECT or 80 GET/HEAD, checked public address, no injected credential, no registry disclosure lease), and in addition refuses every host for which `websiteSensitiveOnly("https://" + host)` holds (identity providers, primary email, banking, payment, `login.`/`sso.`/`auth.`/`accounts.` hosts). That filter runs on the requested name before DNS for every host on the lease, registry hosts included, and a refusal is logged as `denied`; a filter that throws refuses. Composition cannot build a desktop lease without the filter (`vm_invalid_setup`). The lease is written over SSH to `/etc/jit-desktop/proxy.env` (`0640 root:jit-desktop`, atomic replace) only when the guest has the desktop session's `jit-desktop` group; otherwise, or when delivery fails, it is revoked at once and the start still succeeds. A held lease is renewed every 10 minutes for 30 minutes while the VM runs, revoked at the next renewal if the VM is no longer running, and revoked before every protected stop (owner stop, idle stop, restore). It is separate from work-session leases: revoking a session never cuts it, and takeover does not touch it. Work-session leases keep the rules above unchanged. Residuals: guest root can read the desktop lease, so anything in the guest can reach any non-sensitive public host while the VM runs (ADR 0084, ADR 0091); the sensitive-host list is a heuristic. +**Desktop browser lease (DESK16-01, ADR 0091 decisions 2, 3 and 5).** A local VM started with the explicit `desktop` opt-in gets one standing proxy lease, `desktop__`, issued by the same proxy after every boot and again when a restarted controller re-attaches a VM that survived (the restarted proxy holds no lease). It reaches the public web under the same rules as work sessions (public DNS name, 443 CONNECT or 80 GET/HEAD, checked public address, no injected credential, no registry disclosure lease), and in addition refuses every host for which `websiteSensitiveOnly("https://" + host)` holds (identity providers, primary email, banking, payment, `login.`/`sso.`/`auth.`/`accounts.` hosts). That filter runs on the requested name before DNS for every host on the lease, registry hosts included, and a refusal is logged as `denied`; a filter that throws refuses. Composition cannot build a desktop lease without the filter (`vm_invalid_setup`). The lease is written over SSH to `/etc/jit-desktop/proxy.env` (`0640 root:jit-desktop`, atomic replace; a lease URL containing a newline, NUL, space, quote, backslash or `$` is refused, since systemd expands `$` in an environment file) only when the guest's desktop session is completely installed at the pinned version (`/opt/jit-desktop/ready-v1`, written last by the installer) and has the `jit-desktop` group, and only while `inspect()` proves the VM running with its `proxy-only` network profile; otherwise, or when issue or delivery fails (for example a full lease pool), it is revoked at once and the start still succeeds. Provisioning issues it again after cloud-init and the desktop install, so a first boot whose group did not exist yet still ends with a lease. One desktop beat runs every 10 minutes per boot: with the VM running it renews a held lease for 30 minutes, and each renewal starts a fresh byte allowance (`bytesPerSession`, 512 MiB by default, per renewal period), or issues a new lease when none is held; a transient `inspect()` failure (QMP busy with a live snapshot, a management timeout) skips that beat and keeps the lease, since its 30-minute expiry outlives one missed renewal; a VM proven not running, whose boundary is unproven (`vm_boundary_unproven`) or whose network profile is not proven (`network` other than `proxy-only`) has its lease revoked and gets none (fail closed), and a VM no longer running also ends the beat until its next protected start. The lease is revoked before every protected stop (owner stop, idle stop, restore). + +**Desktop session (DESK16-01, ADR 0091 decisions 1 and 6).** With `desktop` on, provisioning (and, for a VM enrolled before `desktop` was on, the first desktop beat of a later boot) runs one fixed idempotent guest program through a separate bounded provisioning lease (unfiltered, like the Docker install; always closed afterwards). It creates the unprivileged system user and group `jit-desktop` (no sudo, home `/var/lib/jit-desktop`), downloads the pinned Chrome for Testing archive for the guest architecture (`qemuDesktopBrowser`: version `157.0.8103.50`, one recorded SHA-256 each for x86_64 and arm64) and refuses any other bytes, unpacks it to `/opt/jit-desktop/chrome-`, installs its `deb.deps` plus Xorg (modesetting, 1280×800), libinput, xinit, fluxbox and fonts from Ubuntu's archive, and enables two units: `jit-desktop-proxy.service` (a loopback forwarder on `127.0.0.1:3129` that adds `Proxy-Authorization` from `proxy.env`, read per connection, because Chromium accepts no credentials in a proxy URL) and `jit-desktop.service` (Xorg, fluxbox and the browser on vt7 as `jit-desktop`, browser restarted if it exits, profile in the user's home, sandbox on through an AppArmor `userns` profile for that binary only). The install removes `ready-v1` first and writes it only after both units and the pinned browser have stayed up for three checks in a row, so a partial, failed or outdated install is installed again. A failed install is tried once per boot and leaves the VM working without a desktop lease. A headless controller installs nothing. It is separate from work-session leases: revoking a session never cuts it, and takeover does not touch it. Work-session leases keep the rules above unchanged. Residuals: guest root can read the desktop lease, and any guest process can use the loopback forwarder, so anything in the guest can reach any non-sensitive public host while the VM runs (ADR 0084, ADR 0091); the sensitive-host list is a heuristic. Proxmox setup registers a VM-scoped API token (VM.Audit/PowerMgmt/Snapshot/Snapshot.Rollback), with read-only Sys.Audit at `/` and `/nodes/`. The protected verifier reads all firewall enable levels, NIC/rules and direct guest egress probes. Missing audit or disabled/unproven controls refuses the `network-isolated` capability. Lima/QEMU verifies the actual protected process/config: plain mode, no shared mounts/guest agent/additional NIC, `restrict=on`, IPv6 disabled and only the fixed proxy guest-forward. Guest firewall settings are diagnostic only. Since revision 4 the default x64/arm64 working VM is the Jit-managed local VM below; Lima/Proxmox are the optional owner-supplied path. diff --git a/docs/contracts/s16-computer-screen-v1.md b/docs/contracts/s16-computer-screen-v1.md index a6ebe561..dc9b30c3 100644 --- a/docs/contracts/s16-computer-screen-v1.md +++ b/docs/contracts/s16-computer-screen-v1.md @@ -1,6 +1,6 @@ # s16-computer-screen/v1 -Owner contract for watching a desktop working computer and taking exclusive control (DESK16-01, [ADR 0090](../adr/0090-s16-watch-and-take-over-a-working-computer.md)). Available when the installation enables `computers` and `desktop`; the bootstrap reports `capabilities.computers.screen: true`. +Owner contract for watching a desktop working computer and taking exclusive control (DESK16-01, [ADR 0090](../adr/0090-s16-watch-and-take-over-a-working-computer.md)). Available when the installation enables `computers` and `desktop`; the bootstrap reports `capabilities.computers.screen: true`. The screen shows the guest desktop session (Xorg, fluxbox and the pinned browser on the 1280×800 virtio display; ADR 0091, `s15-working-vm-v1` "Desktop session") once it is installed; before that, or on a VM whose install failed, it shows the guest's text console. ## Read the screen state diff --git a/src/s15/vm/local-control.ts b/src/s15/vm/local-control.ts index 5b134dbd..d4224d82 100644 --- a/src/s15/vm/local-control.ts +++ b/src/s15/vm/local-control.ts @@ -78,5 +78,8 @@ export async function startLocalVmControl(store: VmStore, options: LocalVmContro const probe = host.probe ?? (options.vmm ? vmmHostProbe(options.vmm) : undefined); // DESK16-01: the desktop browser's standing lease always carries the sensitive-host filter (ADR 0091 decisions 2, 5). const desktopLease = host.desktop && desktopDeny ? (sessionId: string, expiresAt: number) => proxy.lease(sessionId, expiresAt, undefined, { deny: desktopDeny }) : undefined; - return { host: { ...host, proxySocket, ...(probe ? { probe } : {}), ...(desktopLease ? { desktopLease } : {}) }, lease: (sessionId, expiresAt, authorize) => proxy.lease(sessionId, expiresAt, authorize), setHosts: hosts => proxy.setHosts(hosts), close: () => proxy.close() }; + // A desktop VM enrolled before `desktop` was on gets its session installed from the beat through a bounded + // provisioning lease, the same unfiltered kind as the Docker install (ADR 0091 decisions 1, 6). + const provisionLease = desktopLease ? (sessionId: string, expiresAt: number) => proxy.lease(sessionId, expiresAt) : undefined; + return { host: { ...host, proxySocket, ...(probe ? { probe } : {}), ...(desktopLease ? { desktopLease } : {}), ...(provisionLease ? { provisionLease } : {}) }, lease: (sessionId, expiresAt, authorize) => proxy.lease(sessionId, expiresAt, authorize), setHosts: hosts => proxy.setHosts(hosts), close: () => proxy.close() }; } diff --git a/src/s15/vm/qemu.ts b/src/s15/vm/qemu.ts index 3bd2c1b4..8fc2ad36 100644 --- a/src/s15/vm/qemu.ts +++ b/src/s15/vm/qemu.ts @@ -115,7 +115,13 @@ export interface QemuHostOptions { * composed source (`startLocalVmControl`) refuses sensitive-only hosts on it. Absent, no desktop lease exists. */ desktopLease?: (sessionId: string, expiresAt: number) => VmProxyLease; - /** Repeating timer seam for desktop lease renewal (default: an unreferenced `setInterval`); returns its cancel. */ + /** + * DESK16-01 (ADR 0091 decisions 1, 6): bounded provisioning lease (no host filter, like the Docker install) the + * desktop beat uses to install the desktop session on a VM enrolled before `desktop` was on. Absent, only + * provisioning installs it. + */ + provisionLease?: (sessionId: string, expiresAt: number) => VmProxyLease; + /** Repeating timer seam for the desktop beat (default: an unreferenced `setInterval`); returns its cancel. */ every?: (beat: () => void, milliseconds: number) => () => void; } /** Credential-free persisted identity of one Jit-created VM; owner resource edits change only `resources`. */ @@ -160,9 +166,17 @@ export class QemuVmManagement implements VmLocalManagement { readonly #now: () => number; readonly #sleep: (milliseconds: number, signal?: AbortSignal) => Promise; #resources: LocalVmResources; - #desktopLease: { lease: VmProxyLease; cancel: () => void } | null = null; + #desktopLease: VmProxyLease | null = null; /** Bumped by every revoke, so a delivery still in flight when the VM stops never installs its lease. */ #desktopEpoch = 0; + /** Cancels this boot's desktop beat; null while no beat runs. */ + #desktopBeatCancel: (() => void) | null = null; + /** Aborted by every stop, so an in-flight delivery or install ends with it. */ + #desktopAbort = new AbortController(); + /** Desktop lease work in flight (delivery or install); a beat skips while it runs. */ + #desktopWork: Promise | null = null; + /** Whether this boot already tried to install a missing desktop session. */ + #desktopInstallTried = false; constructor(readonly identity: Readonly, readonly host: QemuHostOptions) { if (!/^jit-work-[a-f0-9]{12}$/.test(identity.name) || !["x86_64", "aarch64"].includes(identity.architecture)) vmFail("vm_management_scope_denied", 403); if (!/^52:54:00(?::[a-f0-9]{2}){3}$/.test(identity.mac)) vmFail("vm_invalid_setup"); @@ -347,54 +361,131 @@ export class QemuVmManagement implements VmLocalManagement { if (!running) vmFail("vm_management_unavailable"); this.saveState({ pid, startTicks: this.#proc.startTicks(pid) }); await this.waitForSsh(signal); - await this.openDesktopLease(signal); + this.#desktopInstallTried = false; + // The boot's lease and beat: a lease that cannot be issued or delivered now is retried by the beat. + await this.desktopTurn(() => this.openDesktopLease(signal)); + this.armDesktop(); } /** Whether this manager currently holds a delivered desktop browser lease (DESK16-01). */ get desktopLeaseHeld(): boolean { return this.#desktopLease !== null; } /** * DESK16-01 (ADR 0091 decisions 2-3): the guest desktop browser's standing public-web lease, separate from every - * work-session lease. A fresh lease is issued after each boot and when a controller re-attaches a VM that survived - * its restart (the restarted proxy holds no lease). It is written over SSH to `/etc/jit-desktop/proxy.env` - * (`0640 root:jit-desktop`) only when the guest has the desktop session's group; otherwise, or when delivery fails, - * it is revoked at once and the guest has no desktop lease. A proxy that cannot issue one (e.g. its lease pool is - * full) likewise leaves the VM running without a desktop lease until its next boot or re-attachment. A held lease is renewed while the VM runs and revoked - * before every stop (owner stop, idle stop, restore). A delivery failure never fails the start. + * work-session lease. A fresh lease is issued after each boot, again once provisioning has finished cloud-init and + * installed the desktop session, and when a controller re-attaches a VM that survived its restart (the restarted + * proxy holds no lease). It is written over SSH to `/etc/jit-desktop/proxy.env` (`0640 root:jit-desktop`) only when + * the guest has the desktop session's group; otherwise, or when delivery fails, it is revoked at once. A proxy that + * cannot issue one (e.g. its lease pool is full) leaves the VM running without a lease. Neither ever fails the start: + * the 10-minute desktop beat (`desktopBeat`) renews a held lease and issues a new one while none is held. Every stop + * (owner stop, idle stop, restore) revokes the lease and ends the beat first. + * + * With `install`, a guest without the desktop session first gets it installed (`qemuDesktopProvisioning`) through + * a separate bounded provisioning lease, then the desktop lease is issued. */ - private async openDesktopLease(signal?: AbortSignal): Promise { - const issue = this.host.desktopLease; if (!issue || !this.host.desktop) return; - this.closeDesktopLease(); const epoch = this.#desktopEpoch; + private async openDesktopLease(signal?: AbortSignal, install?: (sessionId: string, expiresAt: number) => VmProxyLease): Promise { + if (!this.host.desktopLease || !this.host.desktop) return; + this.revokeDesktopLease(); const epoch = this.#desktopEpoch, abort = this.#desktopAbort.signal; + // Only a VM whose restricted profile is proven now gets standing web access. + try { if (!this.isolated(await this.inspect())) return; } catch { return; } + if (epoch !== this.#desktopEpoch) return; + const stop = signal ? AbortSignal.any([signal, abort]) : abort; + const result = await this.deliverDesktopLease(epoch, stop); + if (result === "absent" && install && !this.#desktopInstallTried && epoch === this.#desktopEpoch) { + // One install attempt per boot: a failed download is not retried every beat. + this.#desktopInstallTried = true; + if (await this.installDesktop(install, stop) && epoch === this.#desktopEpoch) await this.deliverDesktopLease(epoch, stop); + } + } + /** Issues one desktop lease and writes it into the guest; keeps it only if this epoch is still current. */ + private async deliverDesktopLease(epoch: number, signal: AbortSignal): Promise<"held" | "absent" | "failed"> { // Expiry is judged by the proxy's own wall clock, like work-session and provisioning leases. - // The browser lease is optional: a full or unavailable proxy leaves this VM without one, never fails the start. let lease: VmProxyLease; - try { lease = issue(`desktop_${this.identity.name}_${randomBytes(12).toString("hex")}`, Date.now() + desktopLeaseMs); } catch { return; } - let delivered = false; + try { lease = this.host.desktopLease!(`desktop_${this.identity.name}_${randomBytes(12).toString("hex")}`, Date.now() + desktopLeaseMs); } catch { return "failed"; } + let out = ""; try { - const out = await new VmSshTransport(this.endpoint(), this.#run).control(["sudo", "-n", "python3", "-I", "-c", qemuDesktopLeaseWriter], - { timeoutMs: 60_000, maxBytes: 4096, input: Buffer.from(JSON.stringify({ proxy: lease.environment.HTTPS_PROXY ?? "" })), ...(signal ? { signal } : {}) }); - delivered = out.trim() === "jit-desktop-lease-v1"; - } catch { delivered = false; } - if (!delivered || epoch !== this.#desktopEpoch) { lease.close(); return; } - // Renewed only while this exact VM still runs; a VM that went away without a protected stop loses the lease. - const renew = async () => { - let running = false; try { running = (await this.inspect()).state === "running"; } catch { /* Unproven counts as not running. */ } - if (this.#desktopLease?.lease !== lease) return; - if (!running) { this.closeDesktopLease(); return; } - try { lease.renew?.(Date.now() + desktopLeaseMs); } catch { this.closeDesktopLease(); } - }; - const cancel = (this.host.every ?? repeat)(() => { void renew(); }, desktopLeaseRenewMs); - this.#desktopLease = { lease, cancel }; + out = (await new VmSshTransport(this.endpoint(), this.#run).control(["sudo", "-n", "python3", "-I", "-c", qemuDesktopLeaseWriter], + { timeoutMs: 60_000, maxBytes: 4096, input: Buffer.from(JSON.stringify({ proxy: lease.environment.HTTPS_PROXY ?? "", version: qemuDesktopBrowser.version })), signal })).trim(); + } catch { out = ""; } + if (out !== "jit-desktop-lease-v1" || epoch !== this.#desktopEpoch) { lease.close(); return out === "jit-desktop-absent-v1" ? "absent" : "failed"; } + this.#desktopLease = lease; + return "held"; + } + /** + * Installs the pinned desktop session in the guest (ADR 0091 decisions 1, 6) through one bounded provisioning + * lease, which is always closed afterwards. True only when the guest reports the session ready. + */ + private async installDesktop(issue: (sessionId: string, expiresAt: number) => VmProxyLease, signal: AbortSignal): Promise { + let lease: VmProxyLease; + try { lease = issue(`provision_${randomBytes(16).toString("hex")}`, Date.now() + 1_800_000); } catch { return false; } + try { + const pin = qemuDesktopBrowser[this.identity.architecture]; + const input = Buffer.from(JSON.stringify({ proxy: lease.environment.HTTPS_PROXY ?? "", url: pin.url, sha256: pin.sha256, version: qemuDesktopBrowser.version })); + const out = await this.guest(new VmSshTransport(this.endpoint(), this.#run), ["sudo", "-n", "python3", "-I", "-c", qemuDesktopProvisioning], { timeoutMs: 1_800_000, input, signal }); + return out.trim() === "jit-desktop-ready-v1"; + } catch { return false; } finally { lease.close(); } } - private closeDesktopLease(): void { + /** + * The desktop beat (#454): one timer per boot of a desktop VM, every 10 minutes. + * - running, lease held: renew it (a renewal that fails revokes it; the next beat issues a new one); + * - running, no lease (full pool, failed delivery, a guest still without the session): issue one, installing the + * session first if a provisioning lease source exists and this boot has not tried yet; + * - `inspect()` failed transiently (QMP busy with a live snapshot, a management timeout): skip this beat and keep + * the lease; its 30-minute expiry outlives one missed renewal; + * - the VM is proven not running, its boundary is unproven (`vm_boundary_unproven`) or its network profile is not + * proven (`network` other than `proxy-only`): revoke and issue nothing, failing closed. + * A VM that is no longer running also ends the beat; its next protected start begins a new one. + * A beat that finds earlier desktop work still in flight does nothing. + */ + private async desktopBeat(): Promise { + if (this.#desktopWork) return; + const epoch = this.#desktopEpoch; + let machine: VmMachineState; + try { machine = await this.inspect(); } + catch (error) { + if (error instanceof AppError && error.code === "vm_boundary_unproven" && epoch === this.#desktopEpoch) this.revokeDesktopLease(); + return; + } + if (epoch !== this.#desktopEpoch || this.#desktopWork) return; + if (machine.state !== "running") { this.endDesktop(); return; } + // A running VM whose restricted network profile is not proven (e.g. an extra device) keeps no lease and gets none. + if (!this.isolated(machine)) { this.revokeDesktopLease(); return; } + const held = this.#desktopLease; + if (held) { try { held.renew?.(Date.now() + desktopLeaseMs); } catch { this.revokeDesktopLease(); } return; } + await this.desktopTurn(() => this.openDesktopLease(undefined, this.host.provisionLease)); + } + /** Whether an inspected VM is running with its restricted, proxy-only network profile proven. */ + private isolated(machine: VmMachineState): boolean { return machine.state === "running" && machine.network === "proxy-only"; } + /** Runs one piece of desktop lease work at a time; a caller that must act waits for the earlier one to settle. */ + private async desktopTurn(work: () => Promise): Promise { + while (this.#desktopWork) await this.#desktopWork; + const running = work(), settled: Promise = running.then(() => {}, () => {}).finally(() => { if (this.#desktopWork === settled) this.#desktopWork = null; }); + this.#desktopWork = settled; + await running; + } + /** Starts the desktop beat of this boot (idempotent). Only a desktop controller with a lease source has one. */ + private armDesktop(): void { + if (!this.host.desktop || !this.host.desktopLease || this.#desktopBeatCancel) return; + this.#desktopBeatCancel = (this.host.every ?? repeat)(() => this.desktopBeat(), desktopLeaseRenewMs); + } + /** Revokes the held lease (if any) and invalidates every delivery still in flight. The beat keeps running. */ + private revokeDesktopLease(): void { const held = this.#desktopLease; this.#desktopLease = null; this.#desktopEpoch++; - if (held) { held.cancel(); held.lease.close(); } + held?.close(); + } + /** Ends this boot's desktop state: aborts in-flight delivery or install, revokes the lease and stops the beat. */ + private endDesktop(): void { + this.#desktopAbort.abort(); this.#desktopAbort = new AbortController(); + this.revokeDesktopLease(); + const cancel = this.#desktopBeatCancel; this.#desktopBeatCancel = null; cancel?.(); } /** * Controller-boot re-attachment of a VM that survived the restart, after the caller re-verified it: the restarted - * proxy holds no lease, so a desktop VM gets a fresh desktop lease (DESK16-01). Headless VMs are unchanged. + * proxy holds no lease, so a desktop VM gets a fresh desktop lease and a new beat (DESK16-01). Headless VMs are unchanged. + * The beat is armed before anything can fail: a transient `inspect()` failure here (QMP held by a live snapshot) + * only means no lease yet, and the next beat issues one (#454 (a)); a VM found not running ends the beat there. */ async reattached(signal?: AbortSignal): Promise { - if (this.#desktopLease || (await this.inspect()).state !== "running") return; - await this.openDesktopLease(signal); + this.armDesktop(); + if (!this.#desktopLease) await this.desktopTurn(() => this.openDesktopLease(signal)); } /** * Waits for the guest SSH service, compares its host key to the pinned key (a different key fails closed), @@ -432,8 +523,8 @@ export class QemuVmManagement implements VmLocalManagement { if (this.host.desktop) await this.discardFrame(); } private async halt(): Promise { - // The desktop lease goes first: a stopping or stopped VM keeps no standing web access (DESK16-01). - this.closeDesktopLease(); + // The desktop lease and beat go first: a stopping or stopped VM keeps no standing web access (DESK16-01). + this.endDesktop(); const before = await this.unitState(), recorded = this.state(); this.saveState({ desired: "stopped" }); const pids = [before.pid, recorded.pid ?? 0].filter(pid => pid > 0); @@ -606,6 +697,13 @@ export class QemuVmManagement implements VmLocalManagement { docker = out.trim() === "jit-docker-ready-v1"; } catch { docker = false; } finally { lease.close(); } } + // DESK16-01 (#454): after cloud-init the desktop session is installed (if missing) and the lease is issued again, + // since the boot's first delivery can run before the guest has the session's group. + if (this.host.desktop && this.host.desktopLease && !this.#desktopLease) { + guard(); + this.#desktopInstallTried = false; + await this.desktopTurn(() => this.openDesktopLease(options.signal, options.lease ?? this.host.provisionLease)); + } return { docker }; } } @@ -616,11 +714,16 @@ const repeat = (beat: () => void, milliseconds: number) => { const timer = setIn /** * Fixed guest program (DESK16-01): writes the desktop lease read from stdin to `/etc/jit-desktop/proxy.env`, mode * 0640, owner root, group `jit-desktop` (the desktop session's unprivileged user), replacing it atomically. A guest - * without that group gets nothing written. The file is an environment file for the desktop session's unit. + * whose desktop session is not completely installed at the pinned version (no `/opt/jit-desktop/ready-v1` naming + * it, which the installer writes last) or has no such group gets nothing written and reports it absent, so a + * partial or outdated install is installed again. The file is an environment file for the desktop session's unit. */ export const qemuDesktopLeaseWriter = `import grp,json,os,stat,sys -p=json.loads(sys.stdin.read(4096))['proxy'] -if not p.startswith('http://') or any(c in p for c in '\\n\\r\\0 "\\'\\\\'):print('jit-desktop-lease-invalid-v1');sys.exit(0) +a=json.loads(sys.stdin.read(4096));p,v=a['proxy'],a['version'] +if not p.startswith('http://') or any(c in p for c in '\\n\\r\\0 "$\\'\\\\'):print('jit-desktop-lease-invalid-v1');sys.exit(0) +try:r=open('/opt/jit-desktop/ready-v1').read().strip()==v +except OSError:r=False +if not r:print('jit-desktop-absent-v1');sys.exit(0) try:g=grp.getgrnam('jit-desktop').gr_gid except KeyError:print('jit-desktop-absent-v1');sys.exit(0) d='/etc/jit-desktop' @@ -636,6 +739,196 @@ os.write(f,''.join(k+'='+p+'\\n' for k in ('HTTP_PROXY','HTTPS_PROXY','http_prox os.fsync(f);os.close(f);os.rename(t,d+'/proxy.env') print('jit-desktop-lease-v1') `; +/** + * DESK16-01 (ADR 0091 decision 6): the pinned desktop browser, Chrome for Testing (Google's version-pinned Chromium + * build, no auto-update) for both guest architectures. The SHA-256 of each archive was recorded from the published + * file; the guest refuses any other bytes. Changing the pin is a one-line reviewed change. + */ +export const qemuDesktopBrowser = Object.freeze({ + version: "157.0.8103.50", + x86_64: Object.freeze({ url: "https://storage.googleapis.com/chrome-for-testing-public/157.0.8103.50/linux64/chrome-linux64.zip", + sha256: "d17167bc092eb495a3b768c7467f27e35f625aa03834233a1663af67dcb668c9" }), + aarch64: Object.freeze({ url: "https://storage.googleapis.com/chrome-for-testing-public/157.0.8103.50/linux-arm64/chrome-linux-arm64.zip", + sha256: "9539c1612121eb09c4b787f4f9d0c732357772eb22583620ffed64d50dd6c4cc" }), +}); +/** + * Fixed guest program (DESK16-01, ADR 0091 decisions 1, 6), idempotent. Reads `{proxy, url, sha256, version}` from + * stdin (a bounded provisioning lease and the pin above) and, as root: + * - creates the unprivileged system user and group `jit-desktop` (no sudo, home `/var/lib/jit-desktop`); + * - unless this pinned version is already installed: downloads the archive through the lease, refuses it unless its + * SHA-256 matches, unpacks it to `/opt/jit-desktop/chrome-`, then installs its `deb.deps` and Xorg (the + * modesetting driver), libinput, xinit, fluxbox and fonts from Ubuntu's archive; older pinned versions are removed; + * - writes the Xorg screen (1280×800), an AppArmor profile that lets only that browser use user namespaces (its + * sandbox stays on), the loopback proxy forwarder and the session script; + * - enables `jit-desktop-proxy.service` (127.0.0.1:3129 → the guest-forwarded proxy, adding `Proxy-Authorization` + * from `/etc/jit-desktop/proxy.env` read per connection, since Chromium takes no credentials in a proxy URL) and + * `jit-desktop.service` (Xorg + fluxbox + the browser on vt7 as `jit-desktop`; the browser restarts if it exits). + * Prints `jit-desktop-ready-v1` and writes `/opt/jit-desktop/ready-v1` (the version; removed when an install starts) + * only when every step succeeded and both units and the pinned browser have stayed up for three checks in a row. + */ +export const qemuDesktopProvisioning = String.raw`import hashlib,json,os,pwd,re,shutil,subprocess,sys,time,zipfile +a=json.loads(sys.stdin.read(8192)) +p,u,h,v=a['proxy'],a['url'],a['sha256'],a['version'] +if not p.startswith('http://') or not u.startswith('https://') or len(h)!=64 or not all(c in '0123456789abcdef' for c in h) or not all(c.isdigit() or c=='.' for c in v):print('jit-desktop-invalid-v1');sys.exit(0) +env={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','DEBIAN_FRONTEND':'noninteractive','http_proxy':p,'https_proxy':p,'HTTP_PROXY':p,'HTTPS_PROXY':p} +q={'stdout':subprocess.DEVNULL,'stderr':subprocess.DEVNULL,'env':env,'check':True} +R='/opt/jit-desktop';B=R+'/chrome-'+v;L='/usr/local/lib/jit-desktop' +def put(f,s,m=0o644): + os.makedirs(os.path.dirname(f),mode=0o755,exist_ok=True) + t=f+'.jit-new' + with open(t,'w') as o:o.write(s) + os.chmod(t,m);os.rename(t,f) +FORWARD=r'''import base64,os,socket,threading,urllib.parse +E='/etc/jit-desktop/proxy.env';U=('192.168.5.100',3128);S=threading.BoundedSemaphore(64) +def auth(): + try: + for l in open(E): + if l.startswith('HTTPS_PROXY='): + u=urllib.parse.urlsplit(l.strip()[12:]) + if u.scheme=='http' and u.hostname==U[0] and u.port==U[1] and u.username and u.password:return b'Basic '+base64.b64encode((urllib.parse.unquote(u.username)+':'+urllib.parse.unquote(u.password)).encode()) + except (OSError,ValueError):pass +def pipe(a,b): + try: + while True: + d=a.recv(65536) + if not d:break + b.sendall(d) + except OSError:pass + for s in (a,b): + try:s.shutdown(socket.SHUT_RDWR) + except OSError:pass +def serve(c): + r=None + try: + c.settimeout(30);h=b'' + while b'\r\n\r\n' not in h: + d=c.recv(8192) + if not d or len(h)>16384:return + h+=d + head,rest=h.split(b'\r\n\r\n',1);lines=head.split(b'\r\n');k=auth() + if not k:c.sendall(b'HTTP/1.1 503 No desktop lease\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return + lines=[lines[0]]+[l for l in lines[1:] if not l.lower().startswith((b'proxy-authorization:',b'proxy-connection:'))]+[b'Proxy-Authorization: '+k] + r=socket.create_connection(U,timeout=30);r.sendall(b'\r\n'.join(lines)+b'\r\n\r\n'+rest);c.settimeout(None);r.settimeout(None) + t=threading.Thread(target=pipe,args=(r,c),daemon=True);t.start();pipe(c,r);t.join() + except OSError:pass + finally: + c.close() + if r:r.close() + S.release() +l=socket.socket();l.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);l.bind(('127.0.0.1',3129));l.listen(128) +while True: + c,_=l.accept();S.acquire();threading.Thread(target=serve,args=(c,),daemon=True).start() +''' +SESSION=r'''#!/bin/sh +xset s off -dpms +fluxbox & +while :; do + @B@/chrome --user-data-dir=/var/lib/jit-desktop/profile --proxy-server=http://127.0.0.1:3129 --no-first-run --no-default-browser-check --password-store=basic --disable-background-networking --window-position=0,0 --window-size=1280,800 --start-maximized + sleep 2 +done +''' +DESKTOP='''[Unit] +Description=Jit desktop session (DESK16-01) +After=systemd-user-sessions.service jit-desktop-proxy.service +Wants=jit-desktop-proxy.service +Conflicts=getty@tty7.service +[Service] +User=jit-desktop +PAMName=login +TTYPath=/dev/tty7 +StandardInput=tty +StandardOutput=journal +StandardError=journal +UtmpIdentifier=tty7 +UtmpMode=user +WorkingDirectory=/var/lib/jit-desktop +ExecStartPre=+/usr/bin/chvt 7 +ExecStart=/usr/bin/xinit /usr/local/lib/jit-desktop/session -- /usr/bin/Xorg :0 vt7 -keeptty -novtswitch -nolisten tcp +Restart=always +RestartSec=2 +[Install] +WantedBy=multi-user.target +''' +PROXY='''[Unit] +Description=Jit desktop browser proxy forwarder (DESK16-01) +[Service] +User=jit-desktop +ExecStart=/usr/bin/python3 -I /usr/local/lib/jit-desktop/proxy-forward.py +Restart=always +RestartSec=2 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +[Install] +WantedBy=multi-user.target +''' +XORG='''Section "Device" + Identifier "jit-virtio" + Driver "modesetting" +EndSection +Section "Screen" + Identifier "jit-screen" + Device "jit-virtio" + SubSection "Display" + Modes "1280x800" + EndSubSection +EndSection +''' +ARMOR='''abi , +include +profile jit-desktop-chrome /opt/jit-desktop/chrome-*/chrome flags=(unconfined) { + userns, +} +''' +try: + # The completion marker goes first and is written last, so a partial install never counts as installed. + try:os.unlink(R+'/ready-v1') + except FileNotFoundError:pass + subprocess.run(['groupadd','--system','-f','jit-desktop'],**q) + try:pwd.getpwnam('jit-desktop') + except KeyError:subprocess.run(['useradd','--system','--gid','jit-desktop','--groups','video,input,render','--home-dir','/var/lib/jit-desktop','--create-home','--shell','/usr/sbin/nologin','jit-desktop'],**q) + if not os.path.isfile(B+'/chrome'): + os.makedirs(R,mode=0o755,exist_ok=True);z=R+'/chrome.zip';n=B+'.new' + subprocess.run(['curl','-fsSL','--proto','=https','--max-filesize','314572800','--max-time','1500','-o',z,u],**q) + d=hashlib.sha256() + with open(z,'rb') as f: + for c in iter(lambda:f.read(1048576),b''):d.update(c) + if d.hexdigest()!=h:os.unlink(z);print('jit-desktop-unverified-v1');sys.exit(0) + shutil.rmtree(n,ignore_errors=True);os.makedirs(n,mode=0o755) + with zipfile.ZipFile(z) as f: + for i in f.infolist(): + r=i.filename.split('/',1)[1] if '/' in i.filename else '' + if not r or r.endswith('/'):continue + if r.startswith('/') or '..' in r.split('/'):print('jit-desktop-unverified-v1');sys.exit(0) + t=os.path.join(n,r);os.makedirs(os.path.dirname(t),mode=0o755,exist_ok=True) + with f.open(i) as s,open(t,'wb') as o:shutil.copyfileobj(s,o) + os.chmod(t,0o755 if (i.external_attr>>16)&0o111 else 0o644) + deps=[l.strip() for l in open(n+'/deb.deps') if l.strip()] + subprocess.run(['apt-get','-q','update'],timeout=900,**q) + subprocess.run(['apt-get','-q','-y','--no-install-recommends','satisfy',', '.join(deps)],timeout=1200,**q) + subprocess.run(['apt-get','-q','-y','--no-install-recommends','install','xserver-xorg-core','xserver-xorg-input-libinput','xinit','x11-xserver-utils','fluxbox','fonts-dejavu-core','fonts-thai-tlwg','apparmor'],timeout=1200,**q) + os.rename(n,B);os.unlink(z) + for o in os.listdir(R): + if o.startswith('chrome-') and R+'/'+o!=B:shutil.rmtree(R+'/'+o,ignore_errors=True) + put(L+'/proxy-forward.py',FORWARD);put(L+'/session',SESSION.replace('@B@',B),0o755) + put('/etc/X11/xorg.conf.d/10-jit-desktop.conf',XORG);put('/etc/apparmor.d/jit-desktop-chrome',ARMOR) + put('/etc/systemd/system/jit-desktop.service',DESKTOP);put('/etc/systemd/system/jit-desktop-proxy.service',PROXY) + subprocess.run(['apparmor_parser','-r','/etc/apparmor.d/jit-desktop-chrome'],timeout=120,**{**q,'check':False}) + subprocess.run(['systemctl','daemon-reload'],timeout=120,**q) + subprocess.run(['systemctl','enable','--now','jit-desktop-proxy.service','jit-desktop.service'],timeout=120,**q) + # Installed means running: both units active and the pinned browser up as jit-desktop on three checks in a row. + k=0 + for _ in range(60): + up=subprocess.run(['systemctl','is-active','--quiet','jit-desktop-proxy.service','jit-desktop.service'],timeout=30,**{**q,'check':False}).returncode==0 and subprocess.run(['pgrep','-u','jit-desktop','-f','^'+re.escape(B+'/chrome')+'( |$)'],timeout=30,**{**q,'check':False}).returncode==0 + k=k+1 if up else 0 + if k>=3:break + time.sleep(2) + if k<3:print('jit-desktop-missing-v1');sys.exit(0) + os.makedirs(R,mode=0o755,exist_ok=True);put(R+'/ready-v1',v+'\n') + print('jit-desktop-ready-v1') +except (OSError,ValueError,KeyError,zipfile.BadZipFile,subprocess.SubprocessError):print('jit-desktop-missing-v1') +`; /** Fixed guest program: installs Ubuntu's `docker.io` through the bounded provisioning proxy lease read from stdin. */ const dockerProvisioning = `import json,subprocess,sys p=json.loads(sys.stdin.read(4096))['proxy'] diff --git a/tests/fixtures/s15-qemu.ts b/tests/fixtures/s15-qemu.ts index d61c1c56..509f7d68 100644 --- a/tests/fixtures/s15-qemu.ts +++ b/tests/fixtures/s15-qemu.ts @@ -41,6 +41,9 @@ export class FakeQemuHost { /** DESK16-01: whether the guest has the desktop session's `jit-desktop` group; every proxy URL the lease writer got. */ guestDesktopGroup = false; readonly desktopLeases: string[] = []; + /** DESK16-01: every desktop session install request (stdin JSON) and whether the next one fails. */ + readonly desktopInstalls: unknown[] = []; + desktopInstallFails = false; guestReachable = false; guestMac: string | null = null; qmpDown = false; @@ -158,7 +161,13 @@ export class FakeQemuHost { this.guestCommands.push(remote); const out = (s: string) => ({ exitCode: 0, stdout: Buffer.from(s) }); if (remote.includes('cloud-init')) return out('jit-cloud-init:0\n'); - if (remote.includes('jit-desktop-lease-v1')) { this.desktopLeases.push(JSON.parse(Buffer.from(input ?? []).toString()).proxy); return out(this.guestDesktopGroup ? 'jit-desktop-lease-v1\n' : 'jit-desktop-absent-v1\n'); } + // `guestDesktopGroup` stands for a completely installed session at the requested pinned version. + if (remote.includes('jit-desktop-lease-v1')) { const request = JSON.parse(Buffer.from(input ?? []).toString()); this.desktopLeases.push(request.proxy); return out(this.guestDesktopGroup && typeof request.version === 'string' ? 'jit-desktop-lease-v1\n' : 'jit-desktop-absent-v1\n'); } + if (remote.includes('jit-desktop-ready-v1')) { + const request = JSON.parse(Buffer.from(input ?? []).toString()); this.desktopInstalls.push(request); + if (this.desktopInstallFails || !String(request.proxy).includes('@192.168.5.100:3128')) return out('jit-desktop-missing-v1\n'); + this.guestDesktopGroup = true; return out('jit-desktop-ready-v1\n'); + } if (remote.includes('docker.io')) { const proxy = JSON.parse(Buffer.from(input ?? []).toString()).proxy; this.guestDocker = typeof proxy === 'string' && proxy.includes('@192.168.5.100:3128'); return out(this.guestDocker ? 'jit-docker-ready-v1\n' : 'jit-docker-missing-v1\n'); } if (remote.includes('platform.machine')) return out(JSON.stringify({ architecture: 'x86_64', os: 'ubuntu:26.04', cpuCount: Number(qemu[qemu.indexOf('-smp') + 1]), memoryBytes: 8 * GiB, diskBytes: 60 * GiB, sudo: true, docker: this.guestDocker, basis: 'guest_reported' })); if (remote.includes('nicMacs')) return out(JSON.stringify({ nicMacs: [mac] })); diff --git a/tests/s16-desktop-lease.test.ts b/tests/s16-desktop-lease.test.ts index 8da32ad3..76a23f4b 100644 --- a/tests/s16-desktop-lease.test.ts +++ b/tests/s16-desktop-lease.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { connect, createServer } from "node:net"; @@ -11,7 +11,7 @@ import { VmEnrollment } from "../src/s15/vm/enrollment"; import { startLocalVmControl } from "../src/s15/vm/local-control"; import { VmManagers } from "../src/s15/vm/managers"; import { startVmRegistryProxy, type VmProxyLease, type VmProxyLog } from "../src/s15/vm/proxy"; -import { QemuVmManagement, qemuDesktopLeaseWriter, type QemuHostOptions } from "../src/s15/vm/qemu"; +import { QemuVmManagement, qemuDesktopBrowser, qemuDesktopLeaseWriter, qemuDesktopProvisioning, type QemuHostOptions } from "../src/s15/vm/qemu"; import { WorkingVmService } from "../src/s15/vm/service"; import { VmStore } from "../src/s15/vm/store"; import { VmSshTransport } from "../src/s15/vm/transport"; @@ -109,9 +109,14 @@ describe("local VM control composes the desktop lease only with its filter", () expect(await exchange(control.host.proxySocket, request("news.example.co.th:443", auth(lease)))).toContain("403"); expect(lookups).toEqual(["news.example.co.th"]); lease.close(); + // The beat's install lease is the unfiltered provisioning kind, like the Docker install. + const install = control.host.provisionLease!(`provision_${"1".repeat(32)}`, Date.now() + 10_000); + expect(await exchange(control.host.proxySocket, request("accounts.google.com:443", auth(install)))).toContain("403"); + expect(lookups).toEqual(["news.example.co.th", "accounts.google.com"]); + install.close(); const other = new VmStore(":memory:"); cleanups.push(() => other.close()); const headless = await startLocalVmControl(other, { directory: directory() }); cleanups.unshift(headless.close); - expect(headless.host.desktopLease).toBeUndefined(); + expect(headless.host.desktopLease).toBeUndefined(); expect(headless.host.provisionLease).toBeUndefined(); }); }); @@ -119,12 +124,12 @@ describe("desktop lease lifecycle of a local QEMU VM", () => { const identity = { name: "jit-work-0123456789ab", architecture: "x86_64" as const, mac: "52:54:00:12:34:56", sshPort: 22022, resources: { cpus: 5, memoryMiB: 9216, diskGiB: 60 } }; /** Records every desktop lease issued, renewed and revoked, and captures the renewal timer. */ function leases() { - const issued: { id: string; expiresAt: number; renewals: number[]; closed: boolean }[] = [], beats: { beat: () => void; ms: number; cancelled: boolean }[] = []; + const issued: { id: string; expiresAt: number; renewals: number[]; closed: boolean }[] = [], beats: { beat: () => Promise; ms: number; cancelled: boolean }[] = []; const desktopLease = (id: string, expiresAt: number): VmProxyLease => { const record = { id, expiresAt, renewals: [] as number[], closed: false }; issued.push(record); return { environment: Object.freeze({ HTTPS_PROXY: `http://${id}:secret@192.168.5.100:3128` }), close() { record.closed = true; }, renew(at) { record.renewals.push(at); } }; }; - const every = (beat: () => void, ms: number) => { const entry = { beat, ms, cancelled: false }; beats.push(entry); return () => { entry.cancelled = true; }; }; + const every = (beat: () => void, ms: number) => { const entry = { beat: () => beat() as unknown as Promise, ms, cancelled: false }; beats.push(entry); return () => { entry.cancelled = true; }; }; return { issued, beats, options: { desktop: true, desktopLease, every } satisfies Partial }; } function host(group = true) { const h = new FakeQemuHost(); h.guestDesktopGroup = group; cleanups.push(() => h.cleanup()); return h; } @@ -152,21 +157,114 @@ describe("desktop lease lifecycle of a local QEMU VM", () => { l.beats[0]!.beat(); await flush(); expect(l.issued[0]!.closed).toBe(true); expect(l.issued[0]!.renewals).toEqual([]); expect(m.desktopLeaseHeld).toBe(false); }); - test("a proxy that cannot issue a lease (full pool) leaves the VM running without one", async () => { - const h = host(), l = leases(); - const m = new QemuVmManagement(identity, { ...h.options(), ...l.options, desktopLease: () => { throw new AppError("vm_proxy_unavailable", 503); } }); + test("#454 (b): a full lease pool at boot leaves the VM running; a later beat obtains the lease", async () => { + const h = host(), l = leases(); let full = true; + const m = new QemuVmManagement(identity, { ...h.options(), ...l.options, desktopLease: (id, at) => { if (full) throw new AppError("vm_proxy_unavailable", 503); return l.options.desktopLease(id, at); } }); await m.provision(); - expect((await m.inspect()).state).toBe("running"); expect(m.desktopLeaseHeld).toBe(false); expect(l.beats).toEqual([]); expect(h.desktopLeases).toEqual([]); - await m.stop(); await m.start(); - expect((await m.inspect()).state).toBe("running"); expect(m.desktopLeaseHeld).toBe(false); + expect((await m.inspect()).state).toBe("running"); expect(m.desktopLeaseHeld).toBe(false); expect(h.desktopLeases).toEqual([]); + expect(l.beats).toHaveLength(1); expect(l.beats[0]!.cancelled).toBe(false); + await l.beats[0]!.beat(); + expect(m.desktopLeaseHeld).toBe(false); + full = false; await l.beats[0]!.beat(); + expect(m.desktopLeaseHeld).toBe(true); expect(l.issued).toHaveLength(1); expect(h.desktopLeases).toEqual([`http://${l.issued[0]!.id}:secret@192.168.5.100:3128`]); + await l.beats[0]!.beat(); + expect(l.issued).toHaveLength(1); expect(l.issued[0]!.renewals).toHaveLength(1); }); - test("a guest without the desktop group gets no lease; a headless controller issues none", async () => { + test("#454 (a): a live snapshot holding QMP past the 30s inspect timeout skips the beat and keeps the lease", async () => { + const h = host(), l = leases(); let release = () => {}; let holding = false; + const qmp: QemuHostOptions["qmp"] = async (socket, commands, options) => { + if (holding) throw new AppError("vm_management_unavailable", 503); // What qmpExchange reports after its 30s timeout. + if (commands.some(c => c.execute === "blockdev-snapshot-internal-sync")) { holding = true; await new Promise(done => { release = done; }); holding = false; } + return h.qmp(socket, commands, options); + }; + const m = new QemuVmManagement(identity, { ...h.options(), ...l.options, qmp }); + await m.provision(); + const snapshot = m.snapshot(`jit-${"b".repeat(32)}`); + for (let i = 0; i < 200 && !holding; i++) await Bun.sleep(10); + expect(holding).toBe(true); + await l.beats[0]!.beat(); + expect(m.desktopLeaseHeld).toBe(true); expect(l.issued[0]!.closed).toBe(false); expect(l.issued[0]!.renewals).toEqual([]); expect(l.beats[0]!.cancelled).toBe(false); + release(); await snapshot; + await l.beats[0]!.beat(); + expect(l.issued[0]!.renewals).toHaveLength(1); expect(l.issued).toHaveLength(1); + }); + test("an unproven boundary revokes the lease (fail closed); the beat issues a new one once the VM is proven again", async () => { + const h = host(), l = leases(), m = new QemuVmManagement(identity, { ...h.options(), ...l.options }); + await m.provision(); + const [unit] = [...h.units.values()], process = h.processes.get(unit!.pid)!, argv = process.argv; + process.argv = ["/usr/bin/qemu-system-x86_64", "-name", "someone-else"]; + await l.beats[0]!.beat(); + expect(l.issued[0]!.closed).toBe(true); expect(m.desktopLeaseHeld).toBe(false); expect(l.beats[0]!.cancelled).toBe(false); + process.argv = argv; + await l.beats[0]!.beat(); + expect(l.issued).toHaveLength(2); expect(m.desktopLeaseHeld).toBe(true); + }); + test("a running VM whose network profile is not proven gets no lease and loses a held one (Greptile #455)", async () => { + const h = host(), l = leases(), m = new QemuVmManagement(identity, { ...h.options(), ...l.options }); + await m.provision(); + const [unit] = [...h.units.values()], process = h.processes.get(unit!.pid)!, argv = process.argv; + process.argv = [...argv, "-device", "e1000,netdev=extra"]; + expect(await m.inspect()).toMatchObject({ state: "running", network: "unproven" }); + await l.beats[0]!.beat(); + expect(l.issued[0]!.closed).toBe(true); expect(l.issued[0]!.renewals).toEqual([]); expect(m.desktopLeaseHeld).toBe(false); + await l.beats[0]!.beat(); await m.reattached(); + expect(l.issued).toHaveLength(1); expect(m.desktopLeaseHeld).toBe(false); + process.argv = argv; + await l.beats[0]!.beat(); + expect(l.issued).toHaveLength(2); expect(m.desktopLeaseHeld).toBe(true); + }); + test("a guest without the desktop session and no install source gets no lease; the beat keeps trying; headless issues none", async () => { const h = host(false), l = leases(), m = new QemuVmManagement(identity, { ...h.options(), ...l.options }); await m.provision(); - expect(l.issued).toHaveLength(1); expect(l.issued[0]!.closed).toBe(true); expect(l.beats).toEqual([]); expect(m.desktopLeaseHeld).toBe(false); - const h2 = host(), l2 = leases(), headless = new QemuVmManagement(identity, { ...h2.options(), desktopLease: l2.options.desktopLease }); + // Once at boot, once again after cloud-init; both revoked because the guest has no `jit-desktop` group. + expect(l.issued).toHaveLength(2); expect(l.issued.every(x => x.closed)).toBe(true); expect(m.desktopLeaseHeld).toBe(false); + expect(l.beats).toHaveLength(1); + h.guestDesktopGroup = true; await l.beats[0]!.beat(); + expect(l.issued).toHaveLength(3); expect(m.desktopLeaseHeld).toBe(true); + const h2 = host(), l2 = leases(), headless = new QemuVmManagement(identity, { ...h2.options(), desktopLease: l2.options.desktopLease, every: l2.options.every }); await headless.provision(); - expect(l2.issued).toEqual([]); expect(h2.desktopLeases).toEqual([]); + expect(l2.issued).toEqual([]); expect(h2.desktopLeases).toEqual([]); expect(l2.beats).toEqual([]); expect(h2.desktopInstalls).toEqual([]); + }); + test("#454: provisioning installs the pinned session after cloud-init, then issues the lease; its own lease is always closed", async () => { + const h = host(false), l = leases(), provisioning: { id: string; closed: boolean }[] = []; + const lease = (id: string): VmProxyLease => { const r = { id, closed: false }; provisioning.push(r); return { environment: { HTTPS_PROXY: `http://${id}:p@192.168.5.100:3128` }, close() { r.closed = true; } }; }; + const m = new QemuVmManagement(identity, { ...h.options(), ...l.options }); + await m.provision({ lease }); + expect(h.desktopInstalls).toHaveLength(1); + expect(h.desktopInstalls[0]).toEqual({ proxy: expect.stringContaining("@192.168.5.100:3128"), url: qemuDesktopBrowser.x86_64.url, sha256: qemuDesktopBrowser.x86_64.sha256, version: qemuDesktopBrowser.version }); + // Docker's lease and the desktop install's lease are separate and both closed. + expect(provisioning).toHaveLength(2); expect(provisioning.every(p => p.closed && /^provision_[0-9a-f]{32}$/.test(p.id))).toBe(true); + expect(m.desktopLeaseHeld).toBe(true); expect(l.issued.filter(x => !x.closed)).toHaveLength(1); + expect(h.desktopLeases.at(-1)).toContain(l.issued.at(-1)!.id); + }); + test("a failed install is tried once per boot; on a later boot the beat installs it through the provisioning lease source", async () => { + const h = host(false), l = leases(), installs: string[] = []; + const provisionLease = (id: string): VmProxyLease => { installs.push(id); return { environment: { HTTPS_PROXY: `http://${id}:p@192.168.5.100:3128` }, close() {} }; }; + const m = new QemuVmManagement(identity, { ...h.options(), ...l.options, provisionLease }); + h.desktopInstallFails = true; + await m.provision(); + expect(h.desktopInstalls).toHaveLength(1); expect(m.desktopLeaseHeld).toBe(false); + await l.beats[0]!.beat(); + expect(h.desktopInstalls).toHaveLength(1); + await m.stop(); expect(l.beats[0]!.cancelled).toBe(true); + h.desktopInstallFails = false; await m.start(); + expect(h.desktopInstalls).toHaveLength(1); expect(l.beats).toHaveLength(2); + await l.beats[1]!.beat(); + expect(h.desktopInstalls).toHaveLength(2); expect(m.desktopLeaseHeld).toBe(true); expect(installs).toHaveLength(2); + }); + test("#455: a transient inspect failure while re-attaching still arms the beat; the next beat issues the lease", async () => { + const h = host(), first = leases(); + await new QemuVmManagement(identity, { ...h.options(), ...first.options }).provision(); + let busy = true; + const qmp: QemuHostOptions["qmp"] = async (socket, commands, options) => { + if (busy) throw new AppError("vm_management_unavailable", 503); // A live snapshot holding QMP past the 30s timeout. + return h.qmp(socket, commands, options); + }; + const l = leases(), m = new QemuVmManagement(identity, { ...h.options(), ...l.options, qmp }); + await m.reattached(); + expect(l.beats).toHaveLength(1); expect(l.beats[0]!.cancelled).toBe(false); expect(l.issued).toEqual([]); expect(m.desktopLeaseHeld).toBe(false); + busy = false; await l.beats[0]!.beat(); + expect(m.desktopLeaseHeld).toBe(true); expect(l.issued).toHaveLength(1); expect(l.issued[0]!.closed).toBe(false); }); test("a restarted controller re-attaching a surviving desktop VM issues a fresh lease once", async () => { const owner = {}, requireOwner = (a: object) => { if (a !== owner) throw new Error("owner"); }; @@ -191,11 +289,69 @@ describe("desktop lease lifecycle of a local QEMU VM", () => { }); describe("guest lease writer", () => { - const run = (proxy: string) => spawnSync("python3", ["-I", "-c", qemuDesktopLeaseWriter], { input: JSON.stringify({ proxy }), encoding: "utf8" }); + const run = (proxy: string, program = qemuDesktopLeaseWriter) => spawnSync("python3", ["-I", "-c", program], { input: JSON.stringify({ proxy, version: qemuDesktopBrowser.version }), encoding: "utf8" }); test("refuses a lease URL that could break the environment file, and writes nothing without the desktop group", () => { const python = spawnSync("python3", ["-c", "import grp;grp.getgrnam('jit-desktop')"]); if (python.error) return; // No python3 on this runner; the fixed program is exercised in live evidence. - for (const bad of ["https://a:b@192.168.5.100:3128", "http://a:b@192.168.5.100:3128\nLD_PRELOAD=/x", "http://a b@h:1", "http://a:'b@h:1"]) expect(run(bad).stdout.trim()).toBe("jit-desktop-lease-invalid-v1"); + for (const bad of ["https://a:b@192.168.5.100:3128", "http://a:b@192.168.5.100:3128\nLD_PRELOAD=/x", "http://a b@h:1", "http://a:'b@h:1", "http://a:b$HOME@h:1"]) expect(run(bad).stdout.trim()).toBe("jit-desktop-lease-invalid-v1"); if (python.status !== 0) expect(run("http://desktop_x:abc@192.168.5.100:3128").stdout.trim()).toBe("jit-desktop-absent-v1"); }); + test("a partial or outdated install (no completion marker for the pinned version) counts as absent even with the group (Greptile #455)", () => { + const group = spawnSync("python3", ["-c", "import grp,os;print(grp.getgrgid(os.getgid()).gr_name)"], { encoding: "utf8" }); + if (group.error || group.status !== 0) return; + const dir = mkdtempSync(join(tmpdir(), "jit-marker-")), marker = join(dir, "ready-v1"); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + // The runner's own group stands in for `jit-desktop`; the marker path is the only other change. + const program = qemuDesktopLeaseWriter.replace("'/opt/jit-desktop/ready-v1'", `'${marker}'`).replace("grp.getgrnam('jit-desktop')", `grp.getgrnam('${group.stdout.trim()}')`).replace("d='/etc/jit-desktop'", `d='${join(dir, "etc")}'`); + const lease = "http://desktop_x:abc@192.168.5.100:3128"; + expect(run(lease, program).stdout.trim()).toBe("jit-desktop-absent-v1"); + writeFileSync(marker, "1.0.0.0\n"); + expect(run(lease, program).stdout.trim()).toBe("jit-desktop-absent-v1"); + writeFileSync(marker, `${qemuDesktopBrowser.version}\n`); + // Past the marker and group checks the writer reaches its directory checks (not root-owned here), never "absent". + expect(run(lease, program).stdout.trim()).not.toBe("jit-desktop-absent-v1"); + }); +}); + +describe("guest desktop session program", () => { + test("pins one archive per architecture by SHA-256 and parses as Python", () => { + expect(qemuDesktopBrowser.version).toMatch(/^[0-9]+(?:\.[0-9]+){3}$/); + for (const pin of [qemuDesktopBrowser.x86_64, qemuDesktopBrowser.aarch64]) { + expect(pin.url.startsWith(`https://storage.googleapis.com/chrome-for-testing-public/${qemuDesktopBrowser.version}/`)).toBe(true); + expect(pin.sha256).toMatch(/^[0-9a-f]{64}$/); + } + expect(qemuDesktopBrowser.x86_64.sha256).not.toBe(qemuDesktopBrowser.aarch64.sha256); + const parsed = spawnSync("python3", ["-I", "-c", "import ast,sys;ast.parse(sys.stdin.read());print('ok')"], { input: qemuDesktopProvisioning, encoding: "utf8" }); + if (parsed.error) return; + expect(parsed.stdout.trim()).toBe("ok"); + // The browser keeps its sandbox and reaches the web only through the loopback forwarder; the session user has no sudo. + expect(qemuDesktopProvisioning).not.toContain("--no-sandbox"); + expect(qemuDesktopProvisioning).toContain("--proxy-server=http://127.0.0.1:3129"); + expect(qemuDesktopProvisioning).not.toMatch(/sudoers|NOPASSWD/); + // The completion marker is removed before anything else and written only after the running session is verified. + const at = (text: string) => qemuDesktopProvisioning.indexOf(text); + expect(at("os.unlink(R+'/ready-v1')")).toBeGreaterThan(-1); expect(at("os.unlink(R+'/ready-v1')")).toBeLessThan(at("'groupadd'")); + expect(at("'is-active'")).toBeGreaterThan(at("'enable','--now'")); expect(at("put(R+'/ready-v1'")).toBeGreaterThan(at("if k<3:")); + }); + test("the loopback forwarder adds the lease's Proxy-Authorization, replacing any the client sent, and refuses without a lease", async () => { + if (spawnSync("python3", ["-c", "1"]).error) return; + const forward = /FORWARD=r'''([\s\S]*?)'''/.exec(qemuDesktopProvisioning)![1]!; + const heads: string[] = []; + const upstream = createServer(socket => { let head = ""; socket.on("data", b => { head += b.toString("latin1"); if (head.includes("\r\n\r\n")) { heads.push(head); socket.end("HTTP/1.1 200 Connection Established\r\n\r\nok"); } }); }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); cleanups.push(() => new Promise(resolve => upstream.close(() => resolve()))); + const free = createServer(); await new Promise(resolve => free.listen(0, "127.0.0.1", resolve)); + const port = (free.address() as { port: number }).port; await new Promise(resolve => free.close(() => resolve())); + const dir = mkdtempSync(join(tmpdir(), "jit-fwd-")), env = join(dir, "proxy.env"); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(env, "HTTPS_PROXY=http://desktop_x:pw@192.168.5.100:3128\n"); + const code = forward.replace("E='/etc/jit-desktop/proxy.env'", `E='${env}'`).replace("U=('192.168.5.100',3128)", `U=('127.0.0.1',${(upstream.address() as { port: number }).port})`) + .replace("u.hostname==U[0] and u.port==U[1]", "u.hostname=='192.168.5.100' and u.port==3128").replace("l.bind(('127.0.0.1',3129))", `l.bind(('127.0.0.1',${port}))`); + const child = spawn("python3", ["-I", "-c", code], { stdio: "ignore" }); cleanups.push(() => { child.kill(); }); + let answer = ""; + for (let i = 0; i < 50 && !answer; i++) { try { answer = await exchange(port, "CONNECT news.example.co.th:443 HTTP/1.1\r\nHost: news.example.co.th:443\r\nProxy-Authorization: Basic Zm9vOmJhcg==\r\n\r\n"); } catch { await Bun.sleep(50); } } + expect(answer).toContain("200 Connection Established"); + expect(heads).toHaveLength(1); expect(heads[0]!.match(/Proxy-Authorization/g)).toHaveLength(1); + expect(heads[0]).toContain(`Proxy-Authorization: Basic ${Buffer.from("desktop_x:pw").toString("base64")}\r\n`); + rmSync(env); + expect(await exchange(port, "CONNECT news.example.co.th:443 HTTP/1.1\r\n\r\n")).toContain("503"); + expect(heads).toHaveLength(1); + }); });