| author | |
| committer | |
| log | b9fe0a2d019d62087f59445335d64aff80dd140d |
| tree | 9a4eeca93f46aa65bdf048d98abf067480475389 |
| parent | 8a9ff21d277822603ce8121f569bbb7664fa2e6e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
15 files changed, 800 insertions(+), 74 deletions(-)
dashboard/web/components/Sidebar.tsx+1-1| ... | ... | @@ -157,7 +157,7 @@ function VMNav() { |
| 157 | 157 | <div class="nav-group" classList={{ open: open() }} inert={!open()}><div> |
| 158 | 158 | <For each={loaded()?.domains}>{(vm) => <a href={`/vms?vm=${encodeURIComponent(vm.name)}`} class="nav-sub" |
| 159 | 159 | aria-current={selected() === vm.name ? "page" : undefined}> |
| 160 | <Monitor class="icon" /><span class="label">{vm.name}</span><Status health={vm.state === "running" ? "healthy" : vm.state === "crashed" ? "down" : "stopped"} /> | |
| 160 | <Monitor class="icon" /><span class="label">{vm.name}</span><Status health={vm.setup === "failed" || vm.state === "crashed" ? "down" : vm.state === "running" ? vm.setup && vm.setup !== "ready" ? "starting" : "healthy" : "stopped"} /> | |
| 161 | 161 | </a>}</For> |
| 162 | 162 | <Show when={data.error && !loaded()}><span class="nav-sub muted">VMs unavailable</span></Show> |
| 163 | 163 | </div></div> |
dashboard/web/components/VMConsole.tsx+41-11| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { VMMenu } from "./VMMenu.tsx"; |
| 2 | import { createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from "solid-js"; | |
| 2 | import { createEffect, createMemo, createSignal, type JSX, on, onCleanup, Show } from "solid-js"; | |
| 3 | 3 | import { reason } from "../api.ts"; |
| 4 | 4 | import Keyboard from "lucide-solid/icons/keyboard"; |
| 5 | 5 | import Clipboard from "lucide-solid/icons/clipboard"; |
| ... | ... | @@ -7,6 +7,8 @@ import Maximize from "lucide-solid/icons/maximize"; |
| 7 | 7 | import RefreshCw from "lucide-solid/icons/refresh-cw"; |
| 8 | 8 | import Check from "lucide-solid/icons/check"; |
| 9 | 9 | import RemoteKeyboard from "@novnc/keyboard"; |
| 10 | import { VM_SETUP, type Domain } from "../types/vms.ts"; | |
| 11 | import { StatusLabel } from "./Status.tsx"; | |
| 10 | 12 | |
| 11 | 13 | type WindowSurface = { |
| 12 | 14 | id: number; x: number; y: number; width: number; height: number; atlas_x: number; atlas_y: number; |
| ... | ... | @@ -14,8 +16,10 @@ type WindowSurface = { |
| 14 | 16 | }; |
| 15 | 17 | type WindowFrame = { windows: WindowSurface[]; rtpTimestamp: number }; |
| 16 | 18 | |
| 17 | export function VMConsole(props: { name: string; enabled: boolean; active: boolean; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) { | |
| 19 | export function VMConsole(props: { name: string; enabled: boolean; active: boolean; setup?: Domain["setup"]; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) { | |
| 18 | 20 | const enabled = createMemo(() => props.enabled); |
| 21 | const preparing = () => !!props.setup && props.setup !== "ready"; | |
| 22 | let reconnectTimer: number | undefined; | |
| 19 | 23 | let attempt = 0; |
| 20 | 24 | let video!: HTMLVideoElement; |
| 21 | 25 | let canvas!: HTMLCanvasElement; |
| ... | ... | @@ -121,6 +125,13 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 121 | 125 | const keyboard = new RemoteKeyboard(video); |
| 122 | 126 | const focus = () => { if (input?.readyState === "open") keyboard.grab(); }; |
| 123 | 127 | const blur = () => { keyboard.ungrab(); release(); }; |
| 128 | const releaseKeyboard = (event: KeyboardEvent) => { | |
| 129 | if (event.key !== "Escape" || !event.ctrlKey || !event.altKey) return; | |
| 130 | event.preventDefault(); | |
| 131 | event.stopImmediatePropagation(); | |
| 132 | tools()?.querySelector<HTMLElement>("summary")?.focus(); | |
| 133 | }; | |
| 134 | video.addEventListener("keydown", releaseKeyboard, true); | |
| 124 | 135 | video.addEventListener("focus", focus); |
| 125 | 136 | video.addEventListener("blur", blur); |
| 126 | 137 | let peer: RTCPeerConnection | undefined; |
| ... | ... | @@ -171,13 +182,23 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 171 | 182 | const fail = (message: string) => { |
| 172 | 183 | if (current !== attempt || disposed) return; |
| 173 | 184 | setState("Disconnected"); |
| 174 | setProblem(message); | |
| 175 | 185 | disconnect(); |
| 186 | if (preparing() && props.setup !== "failed") { | |
| 187 | setProblem(""); | |
| 188 | reconnectTimer = window.setTimeout(connect, 1000); | |
| 189 | } else setProblem(message); | |
| 176 | 190 | }; |
| 177 | 191 | const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000); |
| 192 | const ready = () => { | |
| 193 | if (current !== attempt || disposed || video.readyState < 2 || input?.readyState !== "open") return; | |
| 194 | clearTimeout(timeout); | |
| 195 | setState("Connected"); | |
| 196 | }; | |
| 178 | 197 | disconnect = () => { |
| 198 | clearTimeout(reconnectTimer); | |
| 179 | 199 | clearTimeout(timeout); |
| 180 | 200 | blur(); |
| 201 | video.removeEventListener("keydown", releaseKeyboard, true); | |
| 181 | 202 | video.removeEventListener("focus", focus); |
| 182 | 203 | video.removeEventListener("blur", blur); |
| 183 | 204 | socket.onclose = null; |
| ... | ... | @@ -219,6 +240,7 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 219 | 240 | movement = peer.createDataChannel("pointer", { ordered: false, maxRetransmits: 0 }); |
| 220 | 241 | keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down }); |
| 221 | 242 | input.onopen = () => { |
| 243 | ready(); | |
| 222 | 244 | if (document.activeElement === video) focus(); |
| 223 | 245 | if (windowsAvailable()) send({ type: "experiment", enabled: separateWindows() }); |
| 224 | 246 | }; |
| ... | ... | @@ -308,10 +330,8 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 308 | 330 | }; |
| 309 | 331 | video.onplaying = () => { |
| 310 | 332 | if (current !== attempt || disposed) return; |
| 311 | clearTimeout(timeout); | |
| 312 | setState("Connected"); | |
| 333 | ready(); | |
| 313 | 334 | compose(); |
| 314 | if (props.active) video.focus(); | |
| 315 | 335 | }; |
| 316 | 336 | } catch (failure) { |
| 317 | 337 | console.error("VM screen connection", failure); |
| ... | ... | @@ -330,7 +350,10 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 330 | 350 | setProblem(""); |
| 331 | 351 | } |
| 332 | 352 | }); |
| 333 | createEffect(() => { if (props.active && connected()) video.focus(); else release(); }); | |
| 353 | createEffect(() => { if (props.active && connected() && !preparing()) video.focus(); else release(); }); | |
| 354 | createEffect(on(() => props.setup, setup => { | |
| 355 | if (setup === "ready" && enabled() && state() === "Disconnected") void connect(); | |
| 356 | }, { defer: true })); | |
| 334 | 357 | onCleanup(() => { disposed = true; disconnect(); }); |
| 335 | 358 | const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => { |
| 336 | 359 | if (!connected() || !width || !height) return; |
| ... | ... | @@ -380,7 +403,8 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 380 | 403 | <button disabled={!props.enabled || state() === "Connecting…"} onClick={connect}><RefreshCw size={14} aria-hidden="true" />Reconnect</button> |
| 381 | 404 | </VMMenu> |
| 382 | 405 | <span class="spacer" /> |
| 383 | <span class="vm-console-status" role="status">{props.enabled ? state() : "Off"}</span> | |
| 406 | <Show when={connected() && !preparing()}><span class="vm-console-status" id={`keyboard-release-${props.name}`}>Ctrl+Alt+Esc releases keyboard</span></Show> | |
| 407 | <span class="vm-console-status" role="status">{preparing() ? "" : props.enabled ? state() : "Off"}</span> | |
| 384 | 408 | </Show> |
| 385 | 409 | </div> |
| 386 | 410 | <Show when={props.active && showStats()}> |
| ... | ... | @@ -390,7 +414,7 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 390 | 414 | <Show when={separateWindows()}><span>{windowCapture() ? "Separate windows" : "Desktop fallback"}</span></Show> |
| 391 | 415 | </div> |
| 392 | 416 | </Show> |
| 393 | <Show when={props.active && problem()}><p class="error vm-console-error" role="alert">{problem()}</p></Show> | |
| 417 | <Show when={props.active && !preparing() && problem()}><p class="error vm-console-error" role="alert">{problem()}</p></Show> | |
| 394 | 418 | <Show when={props.active && showClipboard()}> |
| 395 | 419 | <div class="vm-clipboard"> |
| 396 | 420 | <label class="field">clipboard<textarea class="search" rows="3" value={clipboard()} onInput={(event) => setClipboard(event.currentTarget.value)} /></label> |
| ... | ... | @@ -401,9 +425,15 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole |
| 401 | 425 | </div> |
| 402 | 426 | </div> |
| 403 | 427 | </Show> |
| 404 | <div class="vm-console-screen" hidden={!props.active || !props.enabled}> | |
| 428 | <Show when={props.active && props.enabled && preparing()}> | |
| 429 | <div class="empty vm-setup" role={props.setup === "failed" ? "alert" : "status"}> | |
| 430 | <StatusLabel health={props.setup === "failed" ? "down" : "starting"}>{VM_SETUP[props.setup!]}</StatusLabel> | |
| 431 | <p>{props.setup === "failed" ? "Restart this VM from the Machine menu to retry." : "Your desktop opens here when setup finishes."}</p> | |
| 432 | </div> | |
| 433 | </Show> | |
| 434 | <div class="vm-console-screen" hidden={!props.active || !props.enabled || preparing()}> | |
| 405 | 435 | <canvas ref={canvas} class="vm-window-canvas" hidden aria-hidden="true" /> |
| 406 | <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen" | |
| 436 | <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen" aria-describedby={`keyboard-release-${props.name}`} | |
| 407 | 437 | onContextMenu={(event) => event.preventDefault()} |
| 408 | 438 | onPointerMove={pointer} |
| 409 | 439 | onPointerLeave={() => { cursorInside = false; drawCursor(); }} |
dashboard/web/pages/VMs.css+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | .vms-list th:not(:first-child), .vms-list tr.top > td:not(.title) { width: 1%; white-space: nowrap; } |
| 2 | .vm-setup { flex: 1; display: grid; align-content: center; justify-items: center; gap: 12px; } | |
| 2 | 3 | .vms-list .row-skeleton { height: 36px; margin: 14px var(--gutter); } |
| 3 | 4 | |
| 4 | 5 | .vms-list tbody.vm tr { cursor: pointer; } |
dashboard/web/pages/VMs.tsx+10-7| ... | ... | @@ -19,6 +19,7 @@ import Usb from "lucide-solid/icons/usb"; |
| 19 | 19 | import { createEffect, createMemo, createResource, createSignal, For, lazy, onCleanup, Show } from "solid-js"; |
| 20 | 20 | import { LIVE_INTERVAL } from "../types/model.ts"; |
| 21 | 21 | import type { Domain, DOMAIN_ACTIONS, Hostdev, VMLibrary } from "../types/vms.ts"; |
| 22 | import { VM_SETUP } from "../types/vms.ts"; | |
| 22 | 23 | import { DetailedError, parseResponse } from "hono/client"; |
| 23 | 24 | import { api, queries, reason, unconnected } from "../api.ts"; |
| 24 | 25 | import { VMConsole } from "../components/VMConsole.tsx"; |
| ... | ... | @@ -53,6 +54,7 @@ const running = (vm: Domain) => vm.state === "running" || vm.state === "blocked" |
| 53 | 54 | |
| 54 | 55 | function status(vm: Domain): [StatusKind, string, tip?: string] { |
| 55 | 56 | if (running(vm)) { |
| 57 | if (vm.setup && vm.setup !== "ready") return [vm.setup === "failed" ? "down" : vm.setup === "restarting" ? "restarting" : "starting", VM_SETUP[vm.setup]]; | |
| 56 | 58 | if (vm.startedAt === null) return ["healthy", "up"]; |
| 57 | 59 | const up = Date.now() / 1000 - vm.startedAt!; |
| 58 | 60 | const started = `started ${datetime(vm.startedAt!)}`; |
| ... | ... | @@ -219,7 +221,8 @@ function create(node: { cpus: number; memory: number; availableMemory: number }, |
| 219 | 221 | const generatedName = () => `testbox-${(choice() || "custom").slice(0, 46)}-${suffix}`; |
| 220 | 222 | const [name, setName] = createSignal(generatedName()); |
| 221 | 223 | const recommended = () => selected()?.recommended ?? { vcpus: 2, memory: 4 * GiB, disk: 32 * GiB }; |
| 222 | const [vcpus, setVcpus] = createSignal(Math.min(node.cpus, recommended().vcpus)); | |
| 224 | const maximumCpus = () => selected()?.platform === "macos" ? 1 : node.cpus; | |
| 225 | const [vcpus, setVcpus] = createSignal(Math.min(maximumCpus(), recommended().vcpus)); | |
| 223 | 226 | const [memory, setMemory] = createSignal(Math.min(node.memory, recommended().memory) / GiB); |
| 224 | 227 | const [disk, setDisk] = createSignal(Math.ceil(recommended().disk / GiB)); |
| 225 | 228 | const minimumDisk = () => Math.max(1, Math.ceil((selected()?.capacity ?? 0) / GiB)); |
| ... | ... | @@ -232,7 +235,7 @@ function create(node: { cpus: number; memory: number; availableMemory: number }, |
| 232 | 235 | const previousName = generatedName(); |
| 233 | 236 | setChoice(event.currentTarget.value); |
| 234 | 237 | if (name() === previousName) setName(generatedName()); |
| 235 | setVcpus(Math.min(node.cpus, recommended().vcpus)); | |
| 238 | setVcpus(Math.min(maximumCpus(), recommended().vcpus)); | |
| 236 | 239 | setMemory(Math.min(node.memory, recommended().memory) / GiB); |
| 237 | 240 | setDisk(Math.ceil(recommended().disk / GiB)); |
| 238 | 241 | }}> |
| ... | ... | @@ -255,8 +258,8 @@ function create(node: { cpus: number; memory: number; availableMemory: number }, |
| 255 | 258 | </label> |
| 256 | 259 | <div class="vm-resources"> |
| 257 | 260 | <label class="vm-resource"><span>CPUs<output>{vcpus()}</output></span> |
| 258 | <input name="vcpus" aria-label="CPUs" type="range" min="1" max={node.cpus} value={vcpus()} onInput={(event) => setVcpus(Number(event.currentTarget.value))} /> | |
| 259 | <span class="vm-resource-scale"><span>1</span><span>{node.cpus}</span></span> | |
| 261 | <input name="vcpus" aria-label="CPUs" type="range" min="1" max={maximumCpus()} value={vcpus()} onInput={(event) => setVcpus(Number(event.currentTarget.value))} /> | |
| 262 | <span class="vm-resource-scale"><span>1</span><span>{maximumCpus()}</span></span> | |
| 260 | 263 | </label> |
| 261 | 264 | <label class="vm-resource"><span>Memory<output>{bytes(memory() * GiB)}</output></span> |
| 262 | 265 | <input name="memory" aria-label="Memory" type="range" min="0.5" max={Math.floor(node.memory / GiB)} step="0.5" value={memory()} onInput={(event) => setMemory(Number(event.currentTarget.value))} /> |
| ... | ... | @@ -283,7 +286,7 @@ function create(node: { cpus: number; memory: number; availableMemory: number }, |
| 283 | 286 | const name = String(form.get("name")); |
| 284 | 287 | await parseResponse(api.vms.$post({ json: { |
| 285 | 288 | name, image: String(form.get("image")), mode: form.get("mode") === "preset" ? "preset" : "iso", |
| 286 | firmware: form.get("firmware") === "uefi" ? "uefi" : "bios", platform: form.get("platform") === "windows" ? "windows" : "linux", | |
| 289 | firmware: form.get("firmware") === "uefi" ? "uefi" : "bios", platform: form.get("platform") === "windows" ? "windows" : form.get("platform") === "macos" ? "macos" : "linux", | |
| 287 | 290 | vcpus: Number(form.get("vcpus")), memory: Math.round(Number(form.get("memory")) * GiB), disk: Math.round(Number(form.get("disk")) * GiB), |
| 288 | 291 | } })); |
| 289 | 292 | await refetch(); |
| ... | ... | @@ -625,7 +628,7 @@ export function VMs() { |
| 625 | 628 | <div class="empty vms-empty"><p>This VM isn't in your account.</p><button class="button" onClick={() => setParams({ vm: undefined, tab: undefined })}>VM home</button></div> |
| 626 | 629 | }>{(vm) => <section class="vm-workspace" aria-label={`${name} workspace`}> |
| 627 | 630 | <h1 class="sr-only">{name}</h1> |
| 628 | <VMConsole name={name} enabled={!isOff(vm())} active={activeTab() === "screen"} | |
| 631 | <VMConsole name={name} enabled={!isOff(vm())} active={activeTab() === "screen"} setup={vm().setup} | |
| 629 | 632 | content={(toolbar) => <> |
| 630 | 633 | <Show when={consoleOpened()}><VMSerial name={name} enabled={running(vm()) && vm().platform === "linux" && vm().serial} active={activeTab() === "console"} toolbar={toolbar} /></Show> |
| 631 | 634 | <Show when={activeTab() === "hardware"}><Hardware vm={vm()} refetch={refetch} username={access()?.username} hostname={access()?.hostname} /></Show> |
| ... | ... | @@ -640,7 +643,7 @@ export function VMs() { |
| 640 | 643 | <PowerControls vm={vm()} refetch={refetch} /> |
| 641 | 644 | <button disabled={!running(vm())} onClick={() => restart(vm(), refetch)}><RotateCw size={14} aria-hidden="true" />Restart</button> |
| 642 | 645 | <Show when={vm().state !== "shutdown"}><button disabled={isOff(vm())} onClick={() => forceOff(vm(), refetch)}><Square size={14} aria-hidden="true" />Force off</button></Show> |
| 643 | <Show when={latest().canPublish}><button disabled={vm().state !== "shutoff"} title={vm().state !== "shutoff" ? "Shut down before saving a preset" : undefined} onClick={() => savePreset(vm(), refetch)}><Save size={14} aria-hidden="true" />Save preset</button></Show> | |
| 646 | <Show when={latest().canPublish && vm().platform !== "macos"}><button disabled={vm().state !== "shutoff"} title={vm().state !== "shutoff" ? "Shut down before saving a preset" : undefined} onClick={() => savePreset(vm(), refetch)}><Save size={14} aria-hidden="true" />Save preset</button></Show> | |
| 644 | 647 | <button class="danger" onClick={() => remove(vm(), refetch)}><Trash size={14} aria-hidden="true" />Delete</button> |
| 645 | 648 | </VMMenu> |
| 646 | 649 | <VMMenu label="Media"> |
dashboard/web/types/vms.ts+14-3| ... | ... | @@ -21,6 +21,16 @@ export interface Hostdev { |
| 21 | 21 | iommuGroup: number | null; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | type Platform = "linux" | "windows" | "macos"; | |
| 25 | ||
| 26 | export const VM_SETUP = { | |
| 27 | starting: "Starting Windows…", | |
| 28 | personalizing: "Preparing your account…", | |
| 29 | restarting: "Restarting to finish setup…", | |
| 30 | ready: "Ready", | |
| 31 | failed: "Setup stopped", | |
| 32 | } as const; | |
| 33 | ||
| 24 | 34 | export interface Domain { |
| 25 | 35 | name: string; |
| 26 | 36 | owner: string | null; |
| ... | ... | @@ -44,10 +54,11 @@ export interface Domain { |
| 44 | 54 | usage: { cpu: number; memory: number } | null; |
| 45 | 55 | /** The guest agent channel's state; null when the domain has none. */ |
| 46 | 56 | agent: "connected" | "disconnected" | null; |
| 57 | setup: keyof typeof VM_SETUP | null; | |
| 47 | 58 | disks: Disk[]; |
| 48 | 59 | media: { target: string; source: string | null }[]; |
| 49 | 60 | firmware: "bios" | "uefi"; |
| 50 | platform: "linux" | "windows"; | |
| 61 | platform: Platform; | |
| 51 | 62 | console: boolean; |
| 52 | 63 | serial: boolean; |
| 53 | 64 | /** `source` is the bridge or libvirt network; addresses come from DHCP leases. */ |
| ... | ... | @@ -75,7 +86,7 @@ export interface Preset { |
| 75 | 86 | os: string; |
| 76 | 87 | description: string; |
| 77 | 88 | firmware: "bios" | "uefi"; |
| 78 | platform: "linux" | "windows"; | |
| 89 | platform: Platform; | |
| 79 | 90 | capacity: number; |
| 80 | 91 | createdAt: number; |
| 81 | 92 | recommended: { vcpus: number; memory: number; disk: number }; |
| ... | ... | @@ -85,4 +96,4 @@ export interface VMLibrary { |
| 85 | 96 | installers: Installer[]; |
| 86 | 97 | presets: Preset[]; |
| 87 | 98 | } |
| 88 | export type NewDomain = {mode:"iso"|"preset";firmware:"bios"|"uefi";platform:"linux"|"windows";name:string;image:string;vcpus:number;memory:number;disk:number}; | |
| 99 | export type NewDomain = {mode:"iso"|"preset";firmware:"bios"|"uefi";platform:Platform;name:string;image:string;vcpus:number;memory:number;disk:number}; |
guest/install.ps1+20-7| ... | ... | @@ -1,15 +1,22 @@ |
| 1 | param([string]$Binary, [switch]$Remove) | |
| 1 | param([string]$Binary, [switch]$Remove, [string]$User) | |
| 2 | 2 | $ErrorActionPreference = 'Stop' |
| 3 | 3 | $identity = [Security.Principal.WindowsIdentity]::GetCurrent() |
| 4 | 4 | $principal = New-Object Security.Principal.WindowsPrincipal($identity) |
| 5 | 5 | if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this installer as administrator to access the VirtIO serial port.' } |
| 6 | if ((Get-WmiObject Win32_ComputerSystem).UserName -ne $identity.Name) { throw 'Run this installer from the signed-in administrator account on the VM desktop.' } | |
| 6 | if ($User) { | |
| 7 | if (!$identity.IsSystem) { throw 'Only system provisioning can select another account.' } | |
| 8 | $account = New-Object Security.Principal.NTAccount($env:COMPUTERNAME, $User) | |
| 9 | $sid = $account.Translate([Security.Principal.SecurityIdentifier]).Value | |
| 10 | } else { | |
| 11 | if ((Get-WmiObject Win32_ComputerSystem).UserName -ne $identity.Name) { throw 'Run this installer from the signed-in administrator account on the VM desktop.' } | |
| 12 | $sid = $identity.User.Value | |
| 13 | } | |
| 7 | 14 | $directory = Join-Path $env:ProgramFiles 'Snowglobe\Guest' |
| 8 | 15 | $destination = Join-Path $directory 'snowglobe-guest.exe' |
| 9 | 16 | $service = New-Object -ComObject Schedule.Service |
| 10 | 17 | $service.Connect() |
| 11 | 18 | $folder = $service.GetFolder('\') |
| 12 | $name = 'SnowglobeGuest-' + $identity.User.Value | |
| 19 | $name = 'SnowglobeGuest-' + $sid | |
| 13 | 20 | if ($Remove) { |
| 14 | 21 | try { $folder.GetTask($name).Stop(0); $folder.DeleteTask($name, 0) } catch { if (Test-Path $destination) { throw } } |
| 15 | 22 | $used = $false |
| ... | ... | @@ -20,19 +27,25 @@ if ($Remove) { |
| 20 | 27 | return |
| 21 | 28 | } |
| 22 | 29 | if (!$Binary) { throw 'Pass the path to the guest executable with -Binary.' } |
| 23 | Get-WmiObject Win32_Process -Filter "name='snowglobe-guest.exe'" | Where-Object { $_.ExecutablePath -eq $destination } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } | |
| 30 | Get-WmiObject Win32_Process -Filter "name='snowglobe-guest.exe'" | Where-Object { $_.ExecutablePath -eq $destination } | ForEach-Object { | |
| 31 | $process = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue | |
| 32 | if ($process) { | |
| 33 | $process | Stop-Process -Force | |
| 34 | if (!$process.WaitForExit(10000)) { throw 'The guest agent did not stop. Restart the VM and retry.' } | |
| 35 | } | |
| 36 | } | |
| 24 | 37 | New-Item -ItemType Directory -Path $directory -Force | Out-Null |
| 25 | 38 | Copy-Item -LiteralPath $Binary -Destination $destination -Force |
| 26 | 39 | $task = $service.NewTask(0) |
| 27 | $task.Principal.UserId = $identity.User.Value | |
| 40 | $task.Principal.UserId = $sid | |
| 28 | 41 | $task.Principal.LogonType = 3 |
| 29 | 42 | $task.Principal.RunLevel = 1 |
| 30 | 43 | $task.Settings.ExecutionTimeLimit = 'PT0S' |
| 31 | 44 | $task.Settings.DisallowStartIfOnBatteries = $false |
| 32 | 45 | $task.Settings.StopIfGoingOnBatteries = $false |
| 33 | 46 | $trigger = $task.Triggers.Create(9) |
| 34 | $trigger.UserId = $identity.User.Value | |
| 47 | $trigger.UserId = $sid | |
| 35 | 48 | $action = $task.Actions.Create(0) |
| 36 | 49 | $action.Path = $destination |
| 37 | $registered = $folder.RegisterTaskDefinition($name, $task, 6, $identity.User.Value, $null, 3, $null) | |
| 50 | $registered = $folder.RegisterTaskDefinition($name, $task, 6, $sid, $null, 3, $null) | |
| 38 | 51 | $registered.Run($null) | Out-Null |
guest/osx-provision.py created+227| ... | ... | @@ -0,0 +1,227 @@ |
| 1 | #!/usr/bin/python | |
| 2 | import glob | |
| 3 | import json | |
| 4 | import os | |
| 5 | import plistlib | |
| 6 | import pwd | |
| 7 | import re | |
| 8 | import shutil | |
| 9 | import struct | |
| 10 | import subprocess | |
| 11 | import sys | |
| 12 | import time | |
| 13 | ||
| 14 | ||
| 15 | SCRIPT = "/Library/Scripts/snowglobe-provision.py" | |
| 16 | STATE = "/var/db/snowglobe" | |
| 17 | ||
| 18 | ||
| 19 | def write(path, data, mode=0o600): | |
| 20 | with open(path, "wb") as outgoing: | |
| 21 | outgoing.write(data) | |
| 22 | os.chmod(path, mode) | |
| 23 | ||
| 24 | ||
| 25 | def autologin(username, password): | |
| 26 | # loginwindow requires a NUL terminator and 12-byte blocks. | |
| 27 | key = [125, 137, 82, 35, 210, 188, 221, 234, 163, 185, 31] | |
| 28 | data = bytearray(password.encode("ascii")) | |
| 29 | data.extend([0] * (12 - len(data) % 12)) | |
| 30 | encoded = [value ^ key[index % len(key)] for index, value in enumerate(data)] | |
| 31 | write("/etc/kcpassword", struct.pack("%dB" % len(encoded), *encoded)) | |
| 32 | subprocess.check_call(["/usr/bin/defaults", "write", "/Library/Preferences/com.apple.loginwindow", "autoLoginUser", username]) | |
| 33 | os.chmod("/Library/Preferences/com.apple.loginwindow.plist", 0o644) | |
| 34 | ||
| 35 | ||
| 36 | def main(): | |
| 37 | os.umask(0o077) | |
| 38 | if sys.argv[1:] == ["welcome"]: | |
| 39 | notice = os.path.expanduser("~/VM Login.txt") | |
| 40 | if os.path.isfile(notice): | |
| 41 | subprocess.check_call(["/usr/bin/open", "-a", "TextEdit", notice]) | |
| 42 | return | |
| 43 | if sys.argv[1:] == ["keychain"]: | |
| 44 | password = sys.stdin.read() | |
| 45 | keychain = os.path.expanduser("~/Library/Keychains/login.keychain") | |
| 46 | security = ["/usr/bin/security"] | |
| 47 | with open("/dev/null", "wb") as quiet: | |
| 48 | if os.path.isfile(keychain): | |
| 49 | subprocess.check_call(security + ["lock-keychain", keychain], stdout=quiet, stderr=quiet) | |
| 50 | if subprocess.call(security + ["unlock-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet): | |
| 51 | if subprocess.call(security + ["set-keychain-password", "-o", "password", "-p", password, keychain], stdout=quiet, stderr=quiet): | |
| 52 | raise ValueError("The guest login keychain password could not be updated.") | |
| 53 | elif subprocess.call(security + ["create-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet): | |
| 54 | raise ValueError("The guest login keychain could not be created.") | |
| 55 | if subprocess.call(security + ["unlock-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet): | |
| 56 | raise ValueError("The guest login keychain could not be unlocked.") | |
| 57 | subprocess.check_call(security + ["default-keychain", "-s", keychain]) | |
| 58 | return | |
| 59 | if os.geteuid() != 0: | |
| 60 | raise ValueError("Run guest provisioning as root.") | |
| 61 | version = subprocess.Popen([ | |
| 62 | "/usr/bin/sw_vers", "-productVersion", | |
| 63 | ], stdout=subprocess.PIPE).communicate()[0].strip().split(".")[:2] | |
| 64 | if sys.argv[1:] == ["install"]: | |
| 65 | shutil.copyfile(__file__, SCRIPT) | |
| 66 | os.chmod(SCRIPT, 0o755) | |
| 67 | if not os.path.isdir(STATE): | |
| 68 | os.mkdir(STATE, 0o700) | |
| 69 | plistlib.writePlist({ | |
| 70 | "Label": "net.paperclover.snowglobe.provision", | |
| 71 | "ProgramArguments": ["/usr/bin/python", SCRIPT], | |
| 72 | "RunAtLoad": True, | |
| 73 | "KeepAlive": {"SuccessfulExit": False}, | |
| 74 | "ThrottleInterval": 10, | |
| 75 | "StandardErrorPath": STATE + "/provision.log", | |
| 76 | }, "/Library/LaunchDaemons/net.paperclover.snowglobe.provision.plist") | |
| 77 | os.chmod("/Library/LaunchDaemons/net.paperclover.snowglobe.provision.plist", 0o644) | |
| 78 | if version == ["10", "6"]: | |
| 79 | path = "/System/Library/Extensions/IOHIDFamily.kext/Contents/PlugIns/IOHIDEventDriverSafeBoot.kext/Contents/Info.plist" | |
| 80 | driver = plistlib.readPlist(path) | |
| 81 | personalities = driver["IOKitPersonalities"] | |
| 82 | tablet = dict(next(value for value in personalities.values() if value["IOClass"] == "IOHIDEventDriver" and "VendorID" not in value)) | |
| 83 | # QEMU tablet coordinates cover the full display without Snow Leopard's 15% border. | |
| 84 | tablet.update(VendorID=1575, ProductID=1, IOProbeScore=1000, AbsoluteAxisBoundsRemovalPercentage=0) | |
| 85 | personalities["QEMU USB Tablet"] = tablet | |
| 86 | plistlib.writePlist(driver, path) | |
| 87 | subprocess.check_call(["/usr/bin/touch", "/System/Library/Extensions"]) | |
| 88 | subprocess.check_call(["/usr/sbin/kextcache", "-u", "/"]) | |
| 89 | autologin("sandbox", "password") | |
| 90 | return | |
| 91 | if sys.argv[1:]: | |
| 92 | raise ValueError("Use install or welcome, or omit the argument to provision.") | |
| 93 | if os.path.isfile(STATE + "/prepared"): | |
| 94 | return | |
| 95 | seed = "/Volumes/SNOWGLOBE" | |
| 96 | while not os.path.isfile(seed + "/access.json"): | |
| 97 | if not os.path.isdir(seed): | |
| 98 | os.mkdir(seed, 0o700) | |
| 99 | # Snow Leopard's diskutil plist discovery crashes on the emulated optical drive. | |
| 100 | with open("/dev/null", "wb") as quiet: | |
| 101 | for device in glob.glob("/dev/disk[0-9]*"): | |
| 102 | if subprocess.call(["/sbin/mount", "-t", "cd9660", "-o", "rdonly", device, seed], stdout=quiet, stderr=quiet): | |
| 103 | continue | |
| 104 | if os.path.isfile(seed + "/access.json"): | |
| 105 | break | |
| 106 | subprocess.check_call(["/sbin/umount", seed]) | |
| 107 | time.sleep(2) | |
| 108 | with open(seed + "/access.json") as incoming: | |
| 109 | access = json.load(incoming) | |
| 110 | username = access["username"] | |
| 111 | password = access["password"] | |
| 112 | hostname = access["hostname"] | |
| 113 | if (not re.match(r"^[a-z_][a-z0-9_-]{0,31}\Z", username) or username == "root" | |
| 114 | or not re.match(r"^[A-Za-z0-9_-]{16,64}\Z", password) | |
| 115 | or not re.match(r"^snowglobe-vm-[a-f0-9]{8}\Z", hostname)): | |
| 116 | raise ValueError("The guest provisioning account is malformed.") | |
| 117 | for field in ["ComputerName", "LocalHostName", "HostName"]: | |
| 118 | subprocess.check_call(["/usr/sbin/scutil", "--set", field, hostname]) | |
| 119 | subprocess.check_call(["/usr/bin/pmset", "-a", "sleep", "0", "displaysleep", "0", "disksleep", "0"]) | |
| 120 | # The login keychain needs the base account's GUI session. | |
| 121 | while not re.search(r"(?m)^\s*501\s+.*/Dock\s*$", subprocess.Popen([ | |
| 122 | "/bin/ps", "-axo", "uid,comm", | |
| 123 | ], stdout=subprocess.PIPE).communicate()[0]): | |
| 124 | time.sleep(2) | |
| 125 | account = pwd.getpwuid(501) | |
| 126 | if account.pw_name not in ("sandbox", username): | |
| 127 | raise ValueError("This preset needs a sandbox account with UID 501.") | |
| 128 | home = "/Users/" + username | |
| 129 | if account.pw_name != username: | |
| 130 | if os.path.exists(home): | |
| 131 | raise ValueError("The guest account's home already exists.") | |
| 132 | subprocess.check_call(["/usr/bin/dscl", ".", "-change", "/Users/sandbox", "RecordName", "sandbox", username]) | |
| 133 | if account.pw_dir != home and os.path.isdir(account.pw_dir): | |
| 134 | os.rename(account.pw_dir, home) | |
| 135 | record = "/Users/" + username | |
| 136 | for field, value in [("NFSHomeDirectory", home), ("RealName", username)]: | |
| 137 | subprocess.check_call(["/usr/bin/dscl", ".", "-create", record, field, value]) | |
| 138 | if subprocess.call(["/usr/bin/dscl", ".", "-passwd", record, password]): | |
| 139 | raise ValueError("The guest account password could not be set.") | |
| 140 | if username != "sandbox": | |
| 141 | groups = plistlib.readPlistFromString(subprocess.Popen([ | |
| 142 | "/usr/bin/dscl", "-plist", ".", "-readall", "/Groups", "RecordName", "GroupMembership", | |
| 143 | ], stdout=subprocess.PIPE).communicate()[0]) | |
| 144 | for group in groups: | |
| 145 | if "sandbox" in group.get("dsAttrTypeStandard:GroupMembership", []): | |
| 146 | subprocess.check_call([ | |
| 147 | "/usr/bin/dscl", ".", "-change", "/Groups/" + group["dsAttrTypeStandard:RecordName"][0], | |
| 148 | "GroupMembership", "sandbox", username, | |
| 149 | ]) | |
| 150 | subprocess.check_call(["/usr/sbin/dseditgroup", "-o", "edit", "-a", username, "-t", "user", "admin"]) | |
| 151 | subprocess.check_call(["/usr/bin/dscacheutil", "-flushcache"]) | |
| 152 | keychains = home + "/Library/Keychains" | |
| 153 | if not os.path.isdir(keychains): | |
| 154 | os.makedirs(keychains) | |
| 155 | os.chmod(keychains, 0o700) | |
| 156 | os.chown(keychains, 501, account.pw_gid) | |
| 157 | # Keep keychain passwords out of sudo's command log. | |
| 158 | keychain = subprocess.Popen([ | |
| 159 | "/usr/bin/sudo", "-H", "-u", username, "/usr/bin/python", SCRIPT, "keychain", | |
| 160 | ], stdin=subprocess.PIPE) | |
| 161 | keychain.communicate(password.encode("ascii")) | |
| 162 | if keychain.returncode: | |
| 163 | raise ValueError("The guest login keychain could not be prepared.") | |
| 164 | ssh = home + "/.ssh" | |
| 165 | if not os.path.isdir(ssh): | |
| 166 | os.mkdir(ssh, 0o700) | |
| 167 | shutil.copyfile(seed + "/authorized_keys", ssh + "/authorized_keys") | |
| 168 | os.chmod(ssh + "/authorized_keys", 0o600) | |
| 169 | os.chown(ssh, 501, account.pw_gid) | |
| 170 | os.chown(ssh + "/authorized_keys", 501, account.pw_gid) | |
| 171 | ssh_dir = "/etc/ssh" if os.path.isdir("/etc/ssh") else "/etc" | |
| 172 | for suffix in ["", ".pub"]: | |
| 173 | path = ssh_dir + "/ssh_host_rsa_key" + suffix | |
| 174 | shutil.copyfile(seed + "/ssh_host_rsa_key" + suffix, path) | |
| 175 | os.chmod(path, 0o644 if suffix else 0o600) | |
| 176 | config = ("Protocol 2\nHostKey " + ssh_dir + "/ssh_host_rsa_key\n" | |
| 177 | "PubkeyAuthentication yes\nPasswordAuthentication no\n" | |
| 178 | "ChallengeResponseAuthentication no\nPermitRootLogin no\n" | |
| 179 | "AllowUsers " + username + "\nAllowTcpForwarding no\n" | |
| 180 | "X11Forwarding no\nPermitTunnel no\nUseDNS no\nUsePAM yes\n") | |
| 181 | write(ssh_dir + "/sshd_config", config.encode("ascii")) | |
| 182 | subprocess.check_call(["/usr/sbin/sshd", "-t"]) | |
| 183 | subprocess.check_call(["/usr/sbin/systemsetup", "-setremotelogin", "on"]) | |
| 184 | volume = plistlib.readPlistFromString(subprocess.Popen([ | |
| 185 | "/usr/sbin/diskutil", "info", "-plist", "/", | |
| 186 | ], stdout=subprocess.PIPE).communicate()[0]) | |
| 187 | disk = plistlib.readPlistFromString(subprocess.Popen([ | |
| 188 | "/usr/sbin/diskutil", "info", "-plist", volume["ParentWholeDisk"], | |
| 189 | ], stdout=subprocess.PIPE).communicate()[0]) | |
| 190 | if disk["TotalSize"] - volume["TotalSize"] > 1024 ** 3: | |
| 191 | size = "R" | |
| 192 | if version == ["10", "6"]: | |
| 193 | limits = subprocess.Popen([ | |
| 194 | "/usr/sbin/diskutil", "resizeVolume", "/", "limits", | |
| 195 | ], stdout=subprocess.PIPE).communicate()[0] | |
| 196 | maximum = re.search(r"Maximum size:.*\((\d+) Bytes\)", limits) | |
| 197 | if not maximum: | |
| 198 | raise ValueError("The guest filesystem's maximum size could not be read.") | |
| 199 | size = maximum.group(1) + "B" | |
| 200 | subprocess.check_call(["/usr/sbin/diskutil", "resizeVolume", "/", size]) | |
| 201 | subprocess.check_call([ | |
| 202 | "/usr/sbin/bless", "--folder", "/System/Library/CoreServices", "--bootefi", | |
| 203 | ]) | |
| 204 | notice = home + "/VM Login.txt" | |
| 205 | write(notice, ("VM Login\n\nUsername: " + username + "\nPassword: " + password + "\n").encode("ascii")) | |
| 206 | os.chown(notice, 501, account.pw_gid) | |
| 207 | agents = home + "/Library/LaunchAgents" | |
| 208 | if not os.path.isdir(agents): | |
| 209 | os.makedirs(agents) | |
| 210 | agent = agents + "/net.paperclover.snowglobe.welcome.plist" | |
| 211 | plistlib.writePlist({ | |
| 212 | "Label": "net.paperclover.snowglobe.welcome", | |
| 213 | "ProgramArguments": ["/usr/bin/python", SCRIPT, "welcome"], | |
| 214 | "RunAtLoad": True, | |
| 215 | "KeepAlive": {"SuccessfulExit": False}, | |
| 216 | "ThrottleInterval": 10, | |
| 217 | }, agent) | |
| 218 | os.chmod(agent, 0o644) | |
| 219 | os.chown(agents, 501, account.pw_gid) | |
| 220 | os.chown(agent, 501, account.pw_gid) | |
| 221 | autologin(username, password) | |
| 222 | write(STATE + "/prepared", b"") | |
| 223 | subprocess.check_call(["/sbin/shutdown", "-r", "now"]) | |
| 224 | ||
| 225 | ||
| 226 | if __name__ == "__main__": | |
| 227 | main() |
guest/windows-provision.ps1 created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | param([switch]$Prepare) | |
| 2 | $ErrorActionPreference = 'Stop' | |
| 3 | $ProgressPreference = 'SilentlyContinue' | |
| 4 | $root = 'C:\Snowglobe' | |
| 5 | $status = Join-Path $root 'setup' | |
| 6 | $winlogon = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' | |
| 7 | ||
| 8 | function Set-SetupStatus([string]$stage) { | |
| 9 | $path = Join-Path $status $stage | |
| 10 | if (!(Test-Path $path)) { | |
| 11 | [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::ReadWrite).Dispose() | |
| 12 | } | |
| 13 | } | |
| 14 | ||
| 15 | try { | |
| 16 | if ($Prepare) { | |
| 17 | Remove-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name SnowglobeBootstrap -ErrorAction SilentlyContinue | |
| 18 | Unregister-ScheduledTask -TaskName SnowglobeFinalize -Confirm:$false -ErrorAction SilentlyContinue | |
| 19 | & "$root\install-guest.ps1" -Binary "$root\snowglobe-guest.exe" -User sandbox | |
| 20 | (Get-LocalUser -Name sandbox).SID.Value | Set-Content "$root\template.sid" -Encoding ASCII | |
| 21 | Get-ScheduledTask -TaskName 'SnowglobeGuest-*' | Disable-ScheduledTask | Out-Null | |
| 22 | Get-Process snowglobe-guest -ErrorAction SilentlyContinue | Stop-Process -Force | |
| 23 | $action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Snowglobe\windows-provision.ps1' | |
| 24 | $trigger = New-ScheduledTaskTrigger -AtStartup | |
| 25 | $principal = New-ScheduledTaskPrincipal -UserId SYSTEM -LogonType ServiceAccount -RunLevel Highest | |
| 26 | $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) | |
| 27 | Register-ScheduledTask -TaskName SnowglobeProvision -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null | |
| 28 | $password = [Guid]::NewGuid().ToString('N') + [Guid]::NewGuid().ToString('N') | |
| 29 | Set-LocalUser sandbox -Password (ConvertTo-SecureString $password -AsPlainText -Force) -PasswordNeverExpires $true | |
| 30 | Set-ItemProperty $winlogon AutoAdminLogon '1' | |
| 31 | Set-ItemProperty $winlogon DefaultUserName 'sandbox' | |
| 32 | Set-ItemProperty $winlogon DefaultDomainName $env:COMPUTERNAME | |
| 33 | Set-ItemProperty $winlogon DefaultPassword $password | |
| 34 | Remove-ItemProperty $winlogon AutoLogonCount -ErrorAction SilentlyContinue | |
| 35 | powercfg.exe /hibernate off | |
| 36 | Remove-Item "$root\identity.ini", "$root\provisioned" -Force -ErrorAction SilentlyContinue | |
| 37 | if (Test-Path $status) { Remove-Item $status -Recurse -Force } | |
| 38 | return | |
| 39 | } | |
| 40 | ||
| 41 | New-Item $status -ItemType Directory -Force | Out-Null | |
| 42 | if (Test-Path "$status\ready") { return } | |
| 43 | if (Test-Path "$status\failed") { Remove-Item "$status\failed" -Force } | |
| 44 | Set-SetupStatus personalizing | |
| 45 | $seed = $null | |
| 46 | $deadline = (Get-Date).AddMinutes(1) | |
| 47 | while (!$seed) { | |
| 48 | $seed = 68..90 | ForEach-Object { '{0}:\SNOWGLOB.INI' -f [char]$_ } | Where-Object { Test-Path $_ } | Select-Object -First 1 | |
| 49 | if ($seed) { break } | |
| 50 | if ((Get-Date) -gt $deadline) { throw 'The VM login was not attached.' } | |
| 51 | Start-Sleep -Milliseconds 250 | |
| 52 | } | |
| 53 | $values = @{} | |
| 54 | foreach ($line in Get-Content $seed) { | |
| 55 | $pair = $line.Split('=', 2) | |
| 56 | if ($pair.Count -eq 2) { $values[$pair[0]] = $pair[1] } | |
| 57 | } | |
| 58 | if (Test-Path "$status\restarting") { | |
| 59 | if ($env:COMPUTERNAME -ine $values['HOSTNAME']) { throw 'The VM name was not applied.' } | |
| 60 | $scheduler = New-Object -ComObject Schedule.Service | |
| 61 | $scheduler.Connect() | |
| 62 | $tasks = $scheduler.GetFolder('\').GetTasks(1) | Where-Object { $_.Name -like 'SnowglobeGuest-*' } | |
| 63 | foreach ($task in $tasks) { $task.Enabled = $true } | |
| 64 | $deadline = (Get-Date).AddMinutes(2) | |
| 65 | while (!(Get-Process explorer -ErrorAction SilentlyContinue)) { | |
| 66 | if ((Get-Date) -gt $deadline) { throw 'The desktop did not start.' } | |
| 67 | Start-Sleep -Milliseconds 250 | |
| 68 | } | |
| 69 | foreach ($task in $tasks) { $task.Run($null) | Out-Null } | |
| 70 | while (!(Get-Process explorer -ErrorAction SilentlyContinue) -or !(Get-Process snowglobe-guest -ErrorAction SilentlyContinue)) { | |
| 71 | if ((Get-Date) -gt $deadline) { throw 'The desktop did not start.' } | |
| 72 | Start-Sleep -Milliseconds 250 | |
| 73 | } | |
| 74 | Set-SetupStatus ready | |
| 75 | return | |
| 76 | } | |
| 77 | $username = $values['USERNAME'] | |
| 78 | $password = $values['PASSWORD'] | |
| 79 | $hostname = $values['HOSTNAME'] | |
| 80 | if ($username -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,19}$') { throw 'The VM account name is unsupported.' } | |
| 81 | if ($password.Length -lt 12 -or $password.Length -gt 128 -or $password -match '[\r\n]') { throw 'The VM password is malformed.' } | |
| 82 | if ($hostname -notmatch '^sgvm-[a-f0-9]{8}$') { throw 'The VM name is malformed.' } | |
| 83 | $sid = Get-Content "$root\template.sid" | |
| 84 | $user = Get-LocalUser | Where-Object { $_.SID.Value -eq $sid } | |
| 85 | if (!$user) { throw 'The template account is missing.' } | |
| 86 | if (Get-LocalUser | Where-Object { $_.Name -ieq $username -and $_.SID.Value -ne $sid }) { throw 'The VM account name is already in use.' } | |
| 87 | $user | Set-LocalUser -Password (ConvertTo-SecureString $password -AsPlainText -Force) -PasswordNeverExpires $true | |
| 88 | if ($user.Name -ine $username) { $user | Rename-LocalUser -NewName $username } | |
| 89 | Rename-Computer -NewName $hostname -Force | |
| 90 | Set-ItemProperty $winlogon AutoAdminLogon '1' | |
| 91 | Set-ItemProperty $winlogon DefaultUserName $username | |
| 92 | Set-ItemProperty $winlogon DefaultDomainName $hostname | |
| 93 | Set-ItemProperty $winlogon DefaultPassword $password | |
| 94 | Remove-ItemProperty $winlogon AutoLogonCount -ErrorAction SilentlyContinue | |
| 95 | Set-SetupStatus restarting | |
| 96 | Restart-Computer -Force | |
| 97 | } catch { | |
| 98 | if ($Prepare) { throw } | |
| 99 | Set-SetupStatus failed | |
| 100 | Add-Content "$root\setup.log" $_.Exception.Message | |
| 101 | exit 1 | |
| 102 | } |
guest/windows.c+3| ... | ... | @@ -154,6 +154,7 @@ static void stream(DWORD session) { |
| 154 | 154 | if (begin_period && end_period) begin_period(1); |
| 155 | 155 | receiving.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); |
| 156 | 156 | if (!screen || !dc || !receiving.hEvent) goto done; |
| 157 | SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED); | |
| 157 | 158 | if (frequency.QuadPart <= 0 || frequency.QuadPart > 0xffffffffu) goto done; |
| 158 | 159 | uint32_t capabilities[SG_CAPABILITIES_WORDS] = {modern_windows, 1}; |
| 159 | 160 | if (!emit(SG_CAPABILITIES, capabilities, SG_CAPABILITIES_WORDS, NULL, 0)) goto done; |
| ... | ... | @@ -193,6 +194,7 @@ static void stream(DWORD session) { |
| 193 | 194 | if (request[7] == SG_STREAM) { |
| 194 | 195 | if (capturing != request[15]) { |
| 195 | 196 | capturing = request[15]; |
| 197 | SetThreadExecutionState(ES_CONTINUOUS | (capturing ? ES_DISPLAY_REQUIRED : 0)); | |
| 196 | 198 | initial = 1; last_cursor = NULL; last_flags = ~0u; |
| 197 | 199 | if (!capturing) { sg_capture_stop(); windows_active = 0; } |
| 198 | 200 | } |
| ... | ... | @@ -315,6 +317,7 @@ window_capture: |
| 315 | 317 | Sleep(1); |
| 316 | 318 | } |
| 317 | 319 | done: |
| 320 | SetThreadExecutionState(ES_CONTINUOUS); | |
| 318 | 321 | sg_capture_stop(); |
| 319 | 322 | if (begin_period && end_period) end_period(1); |
| 320 | 323 | if (winmm) FreeLibrary(winmm); |
nixos/configuration.nix+33-3| ... | ... | @@ -6,7 +6,7 @@ let |
| 6 | 6 | proxyToken = "/var/lib/studio/dashboard-proxy.token"; |
| 7 | 7 | hostTools = lib.fileset.toSource { |
| 8 | 8 | root = ../.; |
| 9 | fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/shale-labels.py ../config/shale.json ../dashboard/src/host-protocol.json ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ]; | |
| 9 | fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/shale-labels.py ../config/shale.json ../dashboard/src/host-protocol.json ../tools/vms.py ../tools/vm-ssh.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ]; | |
| 10 | 10 | }; |
| 11 | 11 | nativePkl = pkgs.callPackage ./pkl.nix { }; |
| 12 | 12 | screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]); |
| ... | ... | @@ -16,7 +16,34 @@ let |
| 16 | 16 | msVarsTemplate = true; |
| 17 | 17 | tpmSupport = true; |
| 18 | 18 | }; |
| 19 | ||
| 19 | secureAavmf = pkgs.runCommand "aavmf-secure-2025.02" { | |
| 20 | src = pkgs.fetchurl { | |
| 21 | url = "https://deb.debian.org/debian/pool/main/e/edk2/qemu-efi-aarch64_2025.02-8+deb13u1_all.deb"; | |
| 22 | sha256 = "a00b2411a79c8aeafd95a7c868ac3cd1aab592f1af9fff965f02d81a625276ed"; | |
| 23 | }; | |
| 24 | nativeBuildInputs = [ pkgs.dpkg ]; | |
| 25 | } '' | |
| 26 | dpkg-deb -x "$src" unpacked | |
| 27 | mkdir -p "$out" | |
| 28 | install -m444 unpacked/usr/share/AAVMF/AAVMF_CODE.secboot.fd "$out/code.fd" | |
| 29 | install -m444 unpacked/usr/share/AAVMF/AAVMF_VARS.ms.fd "$out/vars.fd" | |
| 30 | ''; | |
| 31 | windowsArmQemu = (pkgs.qemu.override { | |
| 32 | minimal = true; | |
| 33 | vncSupport = true; | |
| 34 | tpmSupport = true; | |
| 35 | enableBlobs = true; | |
| 36 | hostCpuTargets = [ "aarch64-softmmu" ]; | |
| 37 | }).overrideAttrs (old: { | |
| 38 | version = "11.1.2"; | |
| 39 | src = pkgs.fetchurl { | |
| 40 | url = "https://download.qemu.org/qemu-11.1.2.tar.xz"; | |
| 41 | sha256 = "05l0hfx3kdkl719xqdps2nqcb5h2zn79nmri68qvw65vwj0mc6vk"; | |
| 42 | }; | |
| 43 | patches = []; | |
| 44 | nativeBuildInputs = old.nativeBuildInputs ++ (with pkgs.python3Packages; [ qemu-qmp setuptools wheel pip ]); | |
| 45 | buildInputs = old.buildInputs ++ [ pkgs.pixman pkgs.libtasn1 ]; | |
| 46 | }); | |
| 20 | 47 | in |
| 21 | 48 | { |
| 22 | 49 | nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ]; |
| ... | ... | @@ -158,7 +185,7 @@ in |
| 158 | 185 | wantedBy = [ "multi-user.target" ]; |
| 159 | 186 | wants = [ "podman.socket" ]; |
| 160 | 187 | after = [ "podman.socket" ]; |
| 161 | path = [ pkgs.zfs pkgs.util-linux pkgs.python3 pkgs.libvirt pkgs.qemu pkgs.cdrkit pkgs.openssl pkgs.podman pkgs.nomad pkgs.systemd pkgs.rsync pkgs.nixos-rebuild nativePkl ] | |
| 188 | path = [ pkgs.zfs pkgs.util-linux pkgs.python3 pkgs.libvirt pkgs.qemu pkgs.gptfdisk pkgs.cdrkit pkgs.openssl pkgs.openssh pkgs.podman pkgs.nomad pkgs.systemd pkgs.rsync pkgs.nixos-rebuild nativePkl ] | |
| 162 | 189 | ++ lib.optional (builtins.elem "nvidia" config.services.xserver.videoDrivers) (lib.getBin config.hardware.nvidia.package); |
| 163 | 190 | environment = (lib.filterAttrs (name: _: lib.hasPrefix "STUDIO_" name) config.environment.variables) // { |
| 164 | 191 | STUDIO_POOL = lib.attrByPath [ "STUDIO_POOL" ] "studio-demo" config.environment.variables; |
| ... | ... | @@ -309,5 +336,8 @@ in |
| 309 | 336 | environment.variables.STUDIO_DOMAIN = lib.mkDefault "studio.test"; |
| 310 | 337 | environment.variables.STUDIO_VM_SECURE_OVMF_CODE = secureOvmf.firmware; |
| 311 | 338 | environment.variables.STUDIO_VM_SECURE_OVMF_VARS = secureOvmf.variablesMs; |
| 339 | environment.variables.STUDIO_VM_SECURE_AAVMF_CODE = "${secureAavmf}/code.fd"; | |
| 340 | environment.variables.STUDIO_VM_SECURE_AAVMF_VARS = "${secureAavmf}/vars.fd"; | |
| 341 | environment.variables.STUDIO_VM_WINDOWS_ARM_EMULATOR = "${windowsArmQemu}/bin/qemu-system-aarch64"; | |
| 312 | 342 | environment.systemPackages = with pkgs; [ acl curl jq openssl python3 rsync zfs qemu ] ++ [ nativePkl ]; |
| 313 | 343 | } |
tools/benchmark-windows-init.py created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Measure a disposable Windows clone through completed desktop provisioning.""" | |
| 3 | import argparse | |
| 4 | import json | |
| 5 | import time | |
| 6 | import vms | |
| 7 | ||
| 8 | parser = argparse.ArgumentParser(description=__doc__) | |
| 9 | parser.add_argument('preset') | |
| 10 | parser.add_argument('name') | |
| 11 | args = parser.parse_args() | |
| 12 | spec = {'name': args.name, 'description': 'Disposable Win10 provisioning benchmark', 'image': args.preset, | |
| 13 | 'vcpus': 4, 'memory': 4 * 2**30, 'disk': 64 * 2**30, 'autostart': False, 'start': True, | |
| 14 | 'mode': 'preset', 'firmware': 'uefi', 'platform': 'windows', 'owner': 'fixture-user', 'username': 'fixture'} | |
| 15 | vms.validate('create', spec) | |
| 16 | started = time.monotonic() | |
| 17 | print(json.dumps({'event': 'requested', 'timestamp': time.time(), 'name': args.name}), flush=True) | |
| 18 | vms.create(spec) | |
| 19 | print(json.dumps({'event': 'created', 'seconds': round(time.monotonic() - started, 2)}), flush=True) | |
| 20 | previous = None | |
| 21 | while time.monotonic() - started < 120: | |
| 22 | stage = vms.windows_setup(args.name) | |
| 23 | if stage != previous: | |
| 24 | print(json.dumps({'event': stage, 'seconds': round(time.monotonic() - started, 2)}), flush=True) | |
| 25 | previous = stage | |
| 26 | if stage == 'failed': | |
| 27 | raise RuntimeError('Guest provisioning failed; inspect C:\\Snowglobe\\setup.log.') | |
| 28 | if stage == 'ready': | |
| 29 | if time.monotonic() - started >= 60: | |
| 30 | raise RuntimeError('Completed provisioning exceeded 60 seconds.') | |
| 31 | break | |
| 32 | time.sleep(.5) | |
| 33 | else: | |
| 34 | raise TimeoutError('Guest provisioning did not finish.') |
tools/dashboard-vms-test.py+17-2| ... | ... | @@ -22,6 +22,22 @@ spec.loader.exec_module(host) |
| 22 | 22 | |
| 23 | 23 | |
| 24 | 24 | class VMTests(unittest.TestCase): |
| 25 | def test_windows_setup_closes_milestone_files(self): | |
| 26 | for stage in ['ready', 'failed', 'restarting', 'personalizing']: | |
| 27 | def reply(action, name, payload, *args): | |
| 28 | command = json.loads(payload) | |
| 29 | if command['execute'] == 'guest-file-close': | |
| 30 | self.assertEqual(command['arguments']['handle'], 7) | |
| 31 | return '{"return": {}}' | |
| 32 | if command['arguments']['path'].endswith('\\' + stage): | |
| 33 | return '{"return": 7}' | |
| 34 | raise vms.subprocess.CalledProcessError(1, 'virsh') | |
| 35 | with self.subTest(stage=stage), patch.object(vms, 'virsh', side_effect=reply) as call: | |
| 36 | self.assertEqual(vms.windows_setup('fixture'), stage) | |
| 37 | self.assertEqual(json.loads(call.call_args.args[2])['execute'], 'guest-file-close') | |
| 38 | with patch.object(vms, 'virsh', side_effect=vms.subprocess.CalledProcessError(1, 'virsh')): | |
| 39 | self.assertEqual(vms.windows_setup('fixture'), 'starting') | |
| 40 | ||
| 25 | 41 | def test_guest_channel_uses_libvirts_truncated_domain_name(self): |
| 26 | 42 | name = 'window-stream-win11-test' |
| 27 | 43 | path = f'/run/libvirt/qemu/channel/175-{name[:20]}/{vms.GUEST_CHANNEL}' |
| ... | ... | @@ -88,6 +104,7 @@ class VMTests(unittest.TestCase): |
| 88 | 104 | 'Windows 8 RTM x64.iso': 'windows-q35-bios', |
| 89 | 105 | 'Windows 10 22H2 English x64.iso': 'windows-q35-uefi', |
| 90 | 106 | 'Windows 11 26H2 English x64.iso': 'windows-q35-secure', |
| 107 | 'Windows 11 26H2 English arm64.iso': 'windows-aarch64-nvme', | |
| 91 | 108 | 'Fedora-Workstation-Live-44-1.7.aarch64.iso': 'linux-aarch64-virtio', |
| 92 | 109 | } |
| 93 | 110 | for name, expected in cases.items(): |
| ... | ... | @@ -99,8 +116,6 @@ class VMTests(unittest.TestCase): |
| 99 | 116 | self.assertEqual(vms.profile('linux-aarch64-virtio')['serial'], 'ttyAMA0') |
| 100 | 117 | self.assertEqual(vms.profile('windows-q35-secure')['cpu_mode'], 'host-passthrough') |
| 101 | 118 | self.assertEqual(vms.profile('windows-q35-secure')['clock'], 'utc') |
| 102 | with self.assertRaisesRegex(ValueError, 'Windows ARM'): | |
| 103 | vms.installer_profile(Path('Windows 11 26H2 English arm64.iso'), windows) | |
| 104 | 119 | |
| 105 | 120 | def test_existing_presets_receive_a_safe_profile(self): |
| 106 | 121 | with tempfile.TemporaryDirectory() as temporary: |
tools/prepare-windows-preset.py created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Publish a clean, signed-in sandbox Windows template with the built guest agent.""" | |
| 3 | import argparse | |
| 4 | import base64 | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import time | |
| 8 | import vms | |
| 9 | ||
| 10 | parser = argparse.ArgumentParser(description=__doc__) | |
| 11 | parser.add_argument('name', help='Running disposable template with the sandbox administrator signed in') | |
| 12 | parser.add_argument('id', help='New immutable preset ID') | |
| 13 | parser.add_argument('--supersedes', required=True) | |
| 14 | args = parser.parse_args() | |
| 15 | for name in [args.name, args.id, args.supersedes]: | |
| 16 | vms.ensure_name(name) | |
| 17 | if (vms.PRESETS / args.id).exists(): | |
| 18 | raise ValueError('A preset already has this name.') | |
| 19 | root = Path(__file__).resolve().parent.parent | |
| 20 | preset = vms.read_preset(args.supersedes) | |
| 21 | if preset['platform'] != 'windows' or preset['arch'] != 'x86_64': | |
| 22 | raise ValueError('Select a Windows preset.') | |
| 23 | files = [(root / 'guest/install.ps1', 'install-guest.ps1'), | |
| 24 | (root / 'guest/windows-provision.ps1', 'windows-provision.ps1'), | |
| 25 | (root / 'guest/build/snowglobe-guest-x86_64.exe', 'snowglobe-guest.exe')] | |
| 26 | contents = [(path.read_bytes(), name) for path, name in files] | |
| 27 | ||
| 28 | def qga(operation, **arguments): | |
| 29 | command = {'execute': operation} | |
| 30 | if arguments: | |
| 31 | command['arguments'] = arguments | |
| 32 | return json.loads(vms.virsh('qemu-agent-command', args.name, json.dumps(command), '--timeout', '15'))['return'] | |
| 33 | ||
| 34 | def wait(pid): | |
| 35 | deadline = time.monotonic() + 180 | |
| 36 | while True: | |
| 37 | result = qga('guest-exec-status', pid=pid) | |
| 38 | if result.get('exited'): | |
| 39 | if result.get('exitcode') != 0: | |
| 40 | raise RuntimeError(base64.b64decode(result.get('err-data', '')).decode(errors='replace')) | |
| 41 | return | |
| 42 | if time.monotonic() > deadline: | |
| 43 | raise TimeoutError('Template preparation did not finish; inspect the existing process before retrying.') | |
| 44 | time.sleep(.25) | |
| 45 | ||
| 46 | pid = qga('guest-exec', path='powershell.exe', arg=['-NoProfile', '-NonInteractive', '-Command', | |
| 47 | "New-Item C:\\Snowglobe -ItemType Directory -Force | Out-Null; Stop-ScheduledTask -TaskName SnowglobeProvision -ErrorAction SilentlyContinue"], **{'capture-output': True})['pid'] | |
| 48 | wait(pid) | |
| 49 | for content, name in contents: | |
| 50 | handle = qga('guest-file-open', path='C:\\Snowglobe\\' + name, mode='w') | |
| 51 | try: | |
| 52 | qga('guest-file-write', handle=handle, **{'buf-b64': base64.b64encode(content).decode()}) | |
| 53 | finally: | |
| 54 | qga('guest-file-close', handle=handle) | |
| 55 | pid = qga('guest-exec', path='powershell.exe', arg=['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', | |
| 56 | '-File', 'C:\\Snowglobe\\windows-provision.ps1', '-Prepare'], **{'capture-output': True})['pid'] | |
| 57 | wait(pid) | |
| 58 | vms.virsh('shutdown', args.name, '--mode', 'agent') | |
| 59 | deadline = time.monotonic() + 120 | |
| 60 | while vms.virsh('domstate', args.name).strip() != 'shut off': | |
| 61 | if time.monotonic() > deadline: | |
| 62 | raise TimeoutError('The template did not shut down.') | |
| 63 | time.sleep(.5) | |
| 64 | vms.save_preset({'name': args.name, 'id': args.id, 'os': preset['os'], | |
| 65 | 'description': 'Prepared desktop with cursor integration', 'setup': 'windows-v3', 'supersedes': args.supersedes}) | |
| 66 | (vms.PRESETS / args.id / 'disk.qcow2').chmod(0o440) | |
| 67 | print(args.id) |
tools/vm-ssh.py created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import json | |
| 3 | import os | |
| 4 | import re | |
| 5 | import subprocess | |
| 6 | import sys | |
| 7 | ||
| 8 | import vms | |
| 9 | ||
| 10 | ||
| 11 | def main(): | |
| 12 | if len(sys.argv) != 2: | |
| 13 | raise ValueError("Usage: vm-ssh.py VM") | |
| 14 | name = sys.argv[1] | |
| 15 | vms.ensure_name(name) | |
| 16 | if vms.virsh("domstate", name).strip() != "running": | |
| 17 | raise ValueError("Start this VM before connecting through SSH.") | |
| 18 | directory = vms.DISKS / name | |
| 19 | with os.fdopen(vms.open_regular(directory / "access.json")) as incoming: | |
| 20 | username = json.load(incoming)["username"] | |
| 21 | if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username): | |
| 22 | raise ValueError("This VM has no supported SSH account.") | |
| 23 | forwards = re.findall( | |
| 24 | r"^\s*TCP\[HOST_FORWARD\]\s+\d+\s+(\S+)\s+(\d+)\s+\S+\s+22\s", | |
| 25 | vms.virsh("qemu-monitor-command", name, "--hmp", "info usernet"), | |
| 26 | re.MULTILINE, | |
| 27 | ) | |
| 28 | if len(forwards) != 1 or forwards[0][0] != "127.0.0.1": | |
| 29 | raise ValueError("This VM has no private SSH connection.") | |
| 30 | port = int(forwards[0][1]) | |
| 31 | if not 1 <= port <= 65535: | |
| 32 | raise ValueError("This VM has no private SSH connection.") | |
| 33 | for file in ["ssh_key", "ssh_known_hosts"]: | |
| 34 | os.close(vms.open_regular(directory / file)) | |
| 35 | args = [ | |
| 36 | "ssh", "-F", "/dev/null", "-i", str(directory / "ssh_key"), "-p", str(port), | |
| 37 | "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", | |
| 38 | "-o", "PreferredAuthentications=publickey", "-o", "ForwardAgent=no", | |
| 39 | "-o", "ClearAllForwardings=yes", "-o", "StrictHostKeyChecking=yes", | |
| 40 | "-o", "HostKeyAlias=" + name, | |
| 41 | "-o", "UserKnownHostsFile=" + str(directory / "ssh_known_hosts"), | |
| 42 | "-o", "GlobalKnownHostsFile=/dev/null", "-o", "ConnectTimeout=10", | |
| 43 | "-o", "HostKeyAlgorithms=+ssh-rsa", "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa", | |
| 44 | "-o", "KexAlgorithms=+diffie-hellman-group14-sha1", | |
| 45 | ] | |
| 46 | command = os.environ.get("SSH_ORIGINAL_COMMAND") | |
| 47 | if not command: | |
| 48 | args.append("-tt") | |
| 49 | args.append(username + "@127.0.0.1") | |
| 50 | if command: | |
| 51 | args.append(command) | |
| 52 | os.execvp(args[0], args) | |
| 53 | ||
| 54 | ||
| 55 | if __name__ == "__main__": | |
| 56 | try: | |
| 57 | main() | |
| 58 | except (ValueError, OSError, KeyError) as error: | |
| 59 | print(str(error), file=sys.stderr) | |
| 60 | sys.exit(1) | |
| 61 | except subprocess.CalledProcessError: | |
| 62 | print("The VM's private SSH connection could not be read.", file=sys.stderr) | |
| 63 | sys.exit(1) |
tools/vms.py+167-40| ... | ... | @@ -33,16 +33,22 @@ PROFILES = { |
| 33 | 33 | "network": "virtio", "usb": "qemu-xhci", "clock": "utc", "emulated": True, |
| 34 | 34 | "scsi": True, "video": "virtio", "keyboard": "usb", "serial": "ttyAMA0", |
| 35 | 35 | }, |
| 36 | "windows-aarch64-nvme": { | |
| 37 | "arch": "aarch64", "machine": "virt-11.1", "platform": "windows", "firmware": "uefi", | |
| 38 | "disk": ("nvme0n1", "nvme"), "cd": ("sda", "usb"), "network": "virtio", | |
| 39 | "usb": "qemu-xhci", "clock": "utc", "emulated": True, "secure": True, | |
| 40 | "video": "virtio", "keyboard": "usb", "gic": "3", "tpm": "tpm-tis", | |
| 41 | }, | |
| 36 | 42 | "windows-q35-uefi": { |
| 37 | 43 | "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi", |
| 38 | 44 | "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e", |
| 39 | "usb": "qemu-xhci", "clock": "localtime", "tpm": True, | |
| 45 | "usb": "qemu-xhci", "clock": "localtime", "tpm": "tpm-crb", "cpu_mode": "host-passthrough", | |
| 40 | 46 | }, |
| 41 | 47 | "windows-q35-secure": { |
| 42 | 48 | "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi", |
| 43 | 49 | "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e", |
| 44 | 50 | "usb": "qemu-xhci", "clock": "utc", "cpu_mode": "host-passthrough", |
| 45 | "tpm": True, "secure": True, "smm": True, | |
| 51 | "tpm": "tpm-crb", "secure": True, "smm": True, | |
| 46 | 52 | }, |
| 47 | 53 | "windows-q35-bios": { |
| 48 | 54 | "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "windows", "firmware": "bios", |
| ... | ... | @@ -54,6 +60,12 @@ PROFILES = { |
| 54 | 60 | "disk": ("hda", "ide"), "cd": ("hdc", "ide"), "network": "e1000", |
| 55 | 61 | "usb": "piix3-uhci", "clock": "localtime", "balloon": "none", |
| 56 | 62 | }, |
| 63 | "macos-legacy-q35": { | |
| 64 | "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "macos", "firmware": "uefi", | |
| 65 | "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000-82545em", | |
| 66 | "usb": "ich9-ehci1", "clock": "utc", "cpu": "Penryn", "emulated": True, | |
| 67 | "keyboard": "usb", "balloon": "none", | |
| 68 | }, | |
| 57 | 69 | } |
| 58 | 70 | ACTION_FIELDS = { |
| 59 | 71 | "node": None, "domains": None, "stats": None, |
| ... | ... | @@ -74,10 +86,10 @@ def profile(name): |
| 74 | 86 | |
| 75 | 87 | |
| 76 | 88 | def default_profile(platform, firmware, arch="x86_64"): |
| 89 | if platform == "macos": | |
| 90 | return "macos-legacy-q35" | |
| 77 | 91 | if arch == "aarch64": |
| 78 | if platform != "linux": | |
| 79 | raise ValueError("Windows ARM needs a tested ARM hardware profile.") | |
| 80 | return "linux-aarch64-virtio" | |
| 92 | return "windows-aarch64-nvme" if platform == "windows" else "linux-aarch64-virtio" | |
| 81 | 93 | if platform == "windows": |
| 82 | 94 | return "windows-q35-uefi" if firmware == "uefi" else "windows-q35-bios" |
| 83 | 95 | return "linux-x86-virtio" |
| ... | ... | @@ -110,8 +122,6 @@ def inferred_profile(root): |
| 110 | 122 | firmware = "uefi" if (os_element is not None and (os_element.find("loader") is not None or os_element.get("firmware") == "efi")) else "bios" |
| 111 | 123 | if machine.startswith("pc-i440fx"): |
| 112 | 124 | return "windows-legacy-ide" |
| 113 | if arch == "aarch64": | |
| 114 | return "linux-aarch64-virtio" | |
| 115 | 125 | return default_profile(platform, firmware, arch) |
| 116 | 126 | |
| 117 | 127 | |
| ... | ... | @@ -171,8 +181,10 @@ def validate(action, payload): |
| 171 | 181 | raise ValueError("Choose a signed-in account to create this VM.") |
| 172 | 182 | if payload["mode"] not in {"iso", "preset"}: |
| 173 | 183 | raise ValueError("Choose an installer or a prepared preset.") |
| 174 | if payload["firmware"] not in {"bios", "uefi"} or payload["platform"] not in {"linux", "windows"}: | |
| 184 | if payload["firmware"] not in {"bios", "uefi"} or payload["platform"] not in {"linux", "windows", "macos"}: | |
| 175 | 185 | raise ValueError("Choose supported firmware and guest hardware.") |
| 186 | if payload["platform"] == "macos" and payload["mode"] != "preset": | |
| 187 | raise ValueError("Use a prepared OS X preset.") | |
| 176 | 188 | image = payload["image"] |
| 177 | 189 | validate_image_id(image) |
| 178 | 190 | host = node() |
| ... | ... | @@ -274,6 +286,7 @@ def domains(): |
| 274 | 286 | ticks = int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19]) |
| 275 | 287 | started = boot + ticks / os.sysconf("SC_CLK_TCK") |
| 276 | 288 | agent = root.find("./devices/channel/target[@name='org.qemu.guest_agent.0']") |
| 289 | setup = root.findtext(f"metadata/{NS}vm/{NS}setup") | |
| 277 | 290 | result.append({ |
| 278 | 291 | "name": name, |
| 279 | 292 | "owner": root.findtext(f"metadata/{NS}vm/{NS}owner"), |
| ... | ... | @@ -288,6 +301,7 @@ def domains(): |
| 288 | 301 | "autostart": re.search(r"^Autostart:\s+enable", virsh("dominfo", name), re.MULTILINE) is not None, |
| 289 | 302 | "startedAt": started, |
| 290 | 303 | "agent": agent.get("state", "disconnected") if agent is not None else None, |
| 304 | "setup": windows_setup(name) if setup == "windows-v3" and state == "running" else None, | |
| 291 | 305 | "disks": disks, |
| 292 | 306 | "media": media, |
| 293 | 307 | "firmware": "uefi" if root.find("./os/loader") is not None or root.find("os").get("firmware") == "efi" else "bios", |
| ... | ... | @@ -300,6 +314,20 @@ def domains(): |
| 300 | 314 | return result |
| 301 | 315 | |
| 302 | 316 | |
| 317 | def windows_setup(name): | |
| 318 | for stage in ["ready", "failed", "restarting", "personalizing"]: | |
| 319 | try: | |
| 320 | handle = json.loads(virsh("qemu-agent-command", name, json.dumps({"execute": "guest-file-open", "arguments": {"path": "C:\\Snowglobe\\setup\\" + stage, "mode": "r"}}), "--timeout", "1"))["return"] | |
| 321 | except (subprocess.CalledProcessError, ValueError, KeyError, TypeError): | |
| 322 | continue | |
| 323 | try: | |
| 324 | virsh("qemu-agent-command", name, json.dumps({"execute": "guest-file-close", "arguments": {"handle": handle}}), "--timeout", "1") | |
| 325 | except subprocess.CalledProcessError: | |
| 326 | pass | |
| 327 | return stage | |
| 328 | return "starting" | |
| 329 | ||
| 330 | ||
| 303 | 331 | def ensure_name(name): |
| 304 | 332 | if not isinstance(name, str) or not NAME.fullmatch(name): |
| 305 | 333 | raise ValueError("invalid VM name") |
| ... | ... | @@ -366,8 +394,10 @@ def read_preset(identity): |
| 366 | 394 | result = json.load(incoming) |
| 367 | 395 | with os.fdopen(open_regular(directory / "disk.qcow2"), "rb") as incoming: |
| 368 | 396 | details = json.loads(command("qemu-img", "info", "-U", "--output=json", f"/proc/self/fd/{incoming.fileno()}", pass_fds=(incoming.fileno(),))) |
| 369 | if not standalone(details) or result["firmware"] not in {"bios", "uefi"} or result["platform"] not in {"linux", "windows"}: | |
| 397 | if not standalone(details) or result["firmware"] not in {"bios", "uefi"} or result["platform"] not in {"linux", "windows", "macos"}: | |
| 370 | 398 | raise ValueError("This preset needs to be prepared again.") |
| 399 | if "supersedes" in result: | |
| 400 | ensure_name(result["supersedes"]) | |
| 371 | 401 | result.setdefault("arch", "x86_64") |
| 372 | 402 | result.setdefault("hardwareProfile", default_profile(result["platform"], result["firmware"], result["arch"])) |
| 373 | 403 | checked_profile(result["hardwareProfile"], result["platform"], result["firmware"], result["arch"]) |
| ... | ... | @@ -408,7 +438,8 @@ def library(): |
| 408 | 438 | presets.append(read_preset(file.name)) |
| 409 | 439 | except (ValueError, subprocess.CalledProcessError): |
| 410 | 440 | continue |
| 411 | return {"installers": sorted(installers, key=lambda item: item["os"].lower()), "presets": presets, "arch": "x86_64"} | |
| 441 | superseded = {item.get("supersedes") for item in presets} | |
| 442 | return {"installers": sorted(installers, key=lambda item: item["os"].lower()), "presets": [item for item in presets if item["id"] not in superseded], "arch": "x86_64"} | |
| 412 | 443 | |
| 413 | 444 | |
| 414 | 445 | def managed_directory(path): |
| ... | ... | @@ -481,6 +512,10 @@ def save_preset(spec): |
| 481 | 512 | if virsh("domstate", name).strip() != "shut off": |
| 482 | 513 | raise ValueError("Shut down this VM before saving a preset.") |
| 483 | 514 | root = ET.fromstring(virsh("dumpxml", name, "--inactive")) |
| 515 | if root.findtext(f"metadata/{NS}vm/{NS}platform") == "macos": | |
| 516 | raise ValueError("Use a prepared OS X preset from the OS library.") | |
| 517 | if root.findtext(f"metadata/{NS}vm/{NS}setup") in {"windows-v2", "windows-v3"} and spec.get("setup") != "windows-v3": | |
| 518 | raise ValueError("Prepare a Windows template before publishing another preset.") | |
| 484 | 519 | disks = root.findall("./devices/disk[@device='disk']") |
| 485 | 520 | if len(disks) != 1 or root.findall("./devices/hostdev"): |
| 486 | 521 | raise ValueError("Use a VM with one disk and no passed-through devices.") |
| ... | ... | @@ -490,9 +525,6 @@ def save_preset(spec): |
| 490 | 525 | file = Path(source.get("file")) |
| 491 | 526 | if not file.is_relative_to(DISKS / name): |
| 492 | 527 | raise ValueError("Use a disk stored with this VM.") |
| 493 | details = info(file) | |
| 494 | if not standalone(details): | |
| 495 | raise ValueError("Use a standalone disk without external data files.") | |
| 496 | 528 | base = PRESETS |
| 497 | 529 | managed_directory(base) |
| 498 | 530 | target = base / spec["id"] |
| ... | ... | @@ -503,8 +535,13 @@ def save_preset(spec): |
| 503 | 535 | # Copy from a verified FD; never follow a swapped symlink to a host file. |
| 504 | 536 | with os.fdopen(open_regular(file), "rb") as incoming: |
| 505 | 537 | command("cp", "--reflink=auto", "--sparse=always", f"/proc/self/fd/{incoming.fileno()}", str(temporary / "source"), pass_fds=(incoming.fileno(),)) |
| 506 | if not standalone(info(temporary / "source")): | |
| 507 | raise ValueError("Use a standalone disk without external data files.") | |
| 538 | details = info(temporary / "source") | |
| 539 | if not standalone(details): | |
| 540 | backing = Path(details.get("backing-filename", "")) | |
| 541 | if (details.get("format") != "qcow2" or details.get("format-specific", {}).get("data", {}).get("data-file") | |
| 542 | or backing.parent.parent != PRESETS or backing.name != "disk.qcow2"): | |
| 543 | raise ValueError("Use a standalone disk or an overlay of a prepared preset.") | |
| 544 | read_preset(backing.parent.name) | |
| 508 | 545 | command("qemu-img", "convert", "-O", "qcow2", str(temporary / "source"), str(temporary / "disk.qcow2")) |
| 509 | 546 | (temporary / "source").unlink() |
| 510 | 547 | os_element = root.find("os") |
| ... | ... | @@ -524,6 +561,11 @@ def save_preset(spec): |
| 524 | 561 | "capacity": details["virtual-size"], "createdAt": int(time.time()), |
| 525 | 562 | "recommended": {"vcpus": int(root.findtext("vcpu", "2")), |
| 526 | 563 | "memory": int(root.findtext("memory")) * 1024, "disk": details["virtual-size"]}} |
| 564 | if spec.get("setup") == "windows-v3" and platform == "windows": | |
| 565 | preset["setup"] = "windows-v3" | |
| 566 | if supersedes := spec.get("supersedes"): | |
| 567 | ensure_name(supersedes) | |
| 568 | preset["supersedes"] = supersedes | |
| 527 | 569 | (temporary / "preset.json").write_text(json.dumps(preset)) |
| 528 | 570 | # rename won't overwrite a nonempty preset, including a concurrent publication. |
| 529 | 571 | temporary.rename(target) |
| ... | ... | @@ -546,9 +588,10 @@ def console_target(name): |
| 546 | 588 | return {"port": port, "uuid": root.findtext("uuid")} |
| 547 | 589 | |
| 548 | 590 | |
| 549 | def secure_firmware(): | |
| 550 | code = Path(os.environ.get("STUDIO_VM_SECURE_OVMF_CODE", "")) | |
| 551 | variables = Path(os.environ.get("STUDIO_VM_SECURE_OVMF_VARS", "")) | |
| 591 | def secure_firmware(arch): | |
| 592 | firmware = "AAVMF" if arch == "aarch64" else "OVMF" | |
| 593 | code = Path(os.environ.get(f"STUDIO_VM_SECURE_{firmware}_CODE", "")) | |
| 594 | variables = Path(os.environ.get(f"STUDIO_VM_SECURE_{firmware}_VARS", "")) | |
| 552 | 595 | if not code.is_absolute() or not variables.is_absolute(): |
| 553 | 596 | raise ValueError("Secure UEFI firmware is unavailable on this host.") |
| 554 | 597 | try: |
| ... | ... | @@ -622,6 +665,23 @@ def windows_seed(directory, username): |
| 622 | 665 | command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "SNOWGLOBE", "-joliet", "-rock", str(marker)) |
| 623 | 666 | |
| 624 | 667 | |
| 668 | def macos_seed(directory, username): | |
| 669 | if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root": | |
| 670 | raise ValueError("Use an account username suitable for an OS X guest.") | |
| 671 | hostname = "snowglobe-vm-" + secrets.token_hex(4) | |
| 672 | password = guest_access(directory, username, hostname) | |
| 673 | command("ssh-keygen", "-q", "-t", "rsa", "-b", "3072", "-m", "PEM", "-N", "", "-f", str(directory / "ssh_key")) | |
| 674 | with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary: | |
| 675 | files = Path(temporary) | |
| 676 | (files / "access.json").write_text(json.dumps({"username": username, "password": password, "hostname": hostname})) | |
| 677 | (files / "authorized_keys").write_text('from="10.0.2.2",no-agent-forwarding,no-port-forwarding,no-X11-forwarding ' + (directory / "ssh_key.pub").read_text()) | |
| 678 | command("ssh-keygen", "-q", "-t", "rsa", "-b", "3072", "-m", "PEM", "-N", "", "-f", str(files / "ssh_host_rsa_key")) | |
| 679 | (directory / "ssh_known_hosts").write_text(directory.name + " " + (files / "ssh_host_rsa_key.pub").read_text()) | |
| 680 | command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "SNOWGLOBE", "-joliet", "-rock", str(files)) | |
| 681 | (directory / "ssh_key.pub").unlink() | |
| 682 | (directory / "seed.iso").chmod(0o600) | |
| 683 | ||
| 684 | ||
| 625 | 685 | def create(spec): |
| 626 | 686 | name = spec["name"] |
| 627 | 687 | ensure_name(name) |
| ... | ... | @@ -641,6 +701,8 @@ def create(spec): |
| 641 | 701 | profile_name = installer_profile(source, spec) |
| 642 | 702 | details_profile = profile(profile_name) |
| 643 | 703 | spec = {**spec, "firmware": details_profile["firmware"] or spec["firmware"], "platform": details_profile["platform"]} |
| 704 | if spec["platform"] == "macos" and spec["vcpus"] != 1: | |
| 705 | raise ValueError("Use one vCPU for this OS X preset.") | |
| 644 | 706 | if not preset and source and architecture(source.name) != details_profile["arch"]: |
| 645 | 707 | raise ValueError(f"Choose a {details_profile['arch']} ISO for this hardware profile.") |
| 646 | 708 | if mode == "preset" and spec["disk"] < preset["capacity"]: |
| ... | ... | @@ -652,7 +714,7 @@ def create(spec): |
| 652 | 714 | virsh("pool-start", "vms") |
| 653 | 715 | virsh("pool-autostart", "vms") |
| 654 | 716 | directory = DISKS / name |
| 655 | directory.mkdir(parents=True, exist_ok=False) | |
| 717 | directory.mkdir(mode=0o700, parents=True, exist_ok=False) | |
| 656 | 718 | drive = directory / "disk.qcow2" |
| 657 | 719 | |
| 658 | 720 | def expired(_signum, _frame): |
| ... | ... | @@ -662,7 +724,15 @@ def create(spec): |
| 662 | 724 | signal.alarm(840) |
| 663 | 725 | try: |
| 664 | 726 | os_name = "Other" |
| 665 | if source: | |
| 727 | overlay = preset and not (spec["platform"] == "macos" and spec["disk"] > preset["capacity"]) | |
| 728 | if overlay: | |
| 729 | os_name = preset["os"] | |
| 730 | with os.fdopen(open_regular(source), "rb") as incoming: | |
| 731 | details = json.loads(command("qemu-img", "info", "-U", "--output=json", f"/proc/self/fd/{incoming.fileno()}", pass_fds=(incoming.fileno(),))) | |
| 732 | if not standalone(details): | |
| 733 | raise ValueError("This preset needs to be prepared again.") | |
| 734 | command("qemu-img", "create", "-u", "-f", "qcow2", "-F", details["format"], "-b", str(source), str(drive), str(spec["disk"])) | |
| 735 | elif source: | |
| 666 | 736 | source_fd = open_regular(source) |
| 667 | 737 | frozen = directory / (source.name if source.suffix.lower() == ".iso" else "source-image") |
| 668 | 738 | with os.fdopen(source_fd, "rb") as incoming: |
| ... | ... | @@ -678,12 +748,20 @@ def create(spec): |
| 678 | 748 | if details.get("virtual-size", 0) > spec["disk"]: |
| 679 | 749 | raise ValueError("Choose a disk at least as large as the OS image.") |
| 680 | 750 | source = frozen |
| 681 | if source and source.suffix.lower() != ".iso": | |
| 682 | command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive)) | |
| 683 | if spec["disk"] > info(drive)["virtual-size"]: | |
| 684 | command("qemu-img", "resize", str(drive), str(spec["disk"])) | |
| 751 | if not overlay and source and source.suffix.lower() != ".iso": | |
| 752 | if spec["platform"] == "macos" and spec["disk"] > details["virtual-size"]: | |
| 753 | expanded = directory / "expanded.raw" | |
| 754 | command("qemu-img", "convert", "-O", "raw", str(source), str(expanded)) | |
| 755 | command("qemu-img", "resize", "-f", "raw", str(expanded), str(spec["disk"])) | |
| 756 | command("sgdisk", "--move-second-header", str(expanded)) | |
| 757 | command("qemu-img", "convert", "-O", "qcow2", str(expanded), str(drive)) | |
| 758 | expanded.unlink() | |
| 759 | else: | |
| 760 | command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive)) | |
| 761 | if spec["disk"] > info(drive)["virtual-size"]: | |
| 762 | command("qemu-img", "resize", str(drive), str(spec["disk"])) | |
| 685 | 763 | source.unlink() |
| 686 | else: | |
| 764 | elif not overlay: | |
| 687 | 765 | command("qemu-img", "create", "-f", "qcow2", str(drive), str(spec["disk"])) |
| 688 | 766 | virsh("pool-refresh", "vms") |
| 689 | 767 | domain_type = "qemu" if details_profile.get("emulated") or os.environ.get("STUDIO_VM_ACCEL") == "qemu" else "kvm" |
| ... | ... | @@ -695,16 +773,22 @@ def create(spec): |
| 695 | 773 | ET.SubElement(metadata, NS + "platform").text = spec["platform"] |
| 696 | 774 | ET.SubElement(metadata, NS + "hardwareProfile").text = profile_name |
| 697 | 775 | ET.SubElement(metadata, NS + "owner").text = spec["owner"] |
| 776 | if preset and preset.get("setup") == "windows-v3": | |
| 777 | ET.SubElement(metadata, NS + "setup").text = "windows-v3" | |
| 698 | 778 | ET.SubElement(root, "memory", unit="bytes").text = str(spec["memory"]) |
| 699 | 779 | ET.SubElement(root, "vcpu").text = str(spec["vcpus"]) |
| 700 | 780 | firmware = spec["firmware"] |
| 701 | 781 | os_element = ET.SubElement(root, "os") |
| 702 | 782 | if firmware == "uefi": |
| 703 | 783 | if details_profile.get("secure"): |
| 704 | code, variables = secure_firmware() | |
| 784 | code, variables = secure_firmware(details_profile["arch"]) | |
| 705 | 785 | nvram = directory / "nvram.fd" |
| 706 | 786 | copy_regular(variables, nvram) |
| 707 | ET.SubElement(os_element, "loader", readonly="yes", type="pflash", secure="yes").text = str(code) | |
| 787 | loader = ET.SubElement(os_element, "loader", readonly="yes", type="pflash") | |
| 788 | # Libvirt restricts the secure attribute to q35; AAVMF's enrolled variables enable it on ARM. | |
| 789 | if details_profile["arch"] == "x86_64": | |
| 790 | loader.set("secure", "yes") | |
| 791 | loader.text = str(code) | |
| 708 | 792 | ET.SubElement(os_element, "nvram", template=str(variables), templateFormat="raw", format="raw").text = str(nvram) |
| 709 | 793 | else: |
| 710 | 794 | os_element.set("firmware", "efi") |
| ... | ... | @@ -718,11 +802,19 @@ def create(spec): |
| 718 | 802 | ET.SubElement(os_element, "boot", dev="cdrom" if source and source.suffix.lower() == ".iso" else "hd") |
| 719 | 803 | if source and source.suffix.lower() == ".iso": |
| 720 | 804 | ET.SubElement(os_element, "boot", dev="hd") |
| 721 | ET.SubElement(os_element, "bootmenu", enable="yes", timeout="5000") | |
| 805 | if not preset: | |
| 806 | ET.SubElement(os_element, "bootmenu", enable="yes", timeout="5000") | |
| 722 | 807 | features = ET.SubElement(root, "features") |
| 723 | 808 | ET.SubElement(features, "acpi") |
| 724 | 809 | if details_profile["arch"] == "x86_64": |
| 725 | 810 | ET.SubElement(features, "apic") |
| 811 | if profile_name == "windows-q35-uefi": | |
| 812 | hyperv = ET.SubElement(features, "hyperv", mode="custom") | |
| 813 | for feature in ["relaxed", "vapic", "vpindex", "synic", "stimer", "frequencies", "tlbflush", "ipi"]: | |
| 814 | ET.SubElement(hyperv, feature, state="on") | |
| 815 | ET.SubElement(hyperv, "spinlocks", state="on", retries="8191") | |
| 816 | if gic := details_profile.get("gic"): | |
| 817 | ET.SubElement(features, "gic", version=gic) | |
| 726 | 818 | if details_profile.get("smm"): |
| 727 | 819 | ET.SubElement(features, "smm", state="on") |
| 728 | 820 | if cpu_mode := details_profile.get("cpu_mode"): |
| ... | ... | @@ -730,20 +822,32 @@ def create(spec): |
| 730 | 822 | elif details_profile.get("cpu"): |
| 731 | 823 | cpu = ET.SubElement(root, "cpu", mode="custom", match="exact", check="full") |
| 732 | 824 | ET.SubElement(cpu, "model", fallback="forbid").text = details_profile["cpu"] |
| 825 | ET.SubElement(cpu, "feature", policy="require", name="hypervisor") | |
| 733 | 826 | elif root.get("type") == "qemu": |
| 734 | 827 | ET.SubElement(root, "cpu", mode="maximum") |
| 735 | ET.SubElement(root, "clock", offset=details_profile["clock"]) | |
| 828 | if profile_name == "windows-q35-uefi": | |
| 829 | ET.SubElement(root.find("cpu"), "topology", sockets="1", cores=str(spec["vcpus"]), threads="1") | |
| 830 | clock = ET.SubElement(root, "clock", offset=details_profile["clock"]) | |
| 831 | if profile_name == "windows-q35-uefi": | |
| 832 | ET.SubElement(clock, "timer", name="hypervclock", present="yes") | |
| 736 | 833 | ET.SubElement(root, "on_poweroff").text = "destroy" |
| 737 | 834 | ET.SubElement(root, "on_reboot").text = "restart" |
| 738 | 835 | ET.SubElement(root, "on_crash").text = "destroy" |
| 739 | 836 | devices = ET.SubElement(root, "devices") |
| 740 | ET.SubElement(devices, "emulator").text = f"/run/current-system/sw/bin/qemu-system-{details_profile['arch']}" | |
| 837 | emulator = f"/run/current-system/sw/bin/qemu-system-{details_profile['arch']}" | |
| 838 | if profile_name == "windows-aarch64-nvme": | |
| 839 | emulator = os.environ.get("STUDIO_VM_WINDOWS_ARM_EMULATOR", "") | |
| 840 | if not Path(emulator).is_absolute(): | |
| 841 | raise ValueError("Windows ARM is unavailable on this host. Choose another OS.") | |
| 842 | ET.SubElement(devices, "emulator").text = emulator | |
| 741 | 843 | if details_profile.get("scsi"): |
| 742 | 844 | ET.SubElement(devices, "controller", type="scsi", index="0", model="virtio-scsi") |
| 743 | 845 | primary = ET.SubElement(devices, "disk", type="file", device="disk") |
| 744 | 846 | ET.SubElement(primary, "driver", name="qemu", type="qcow2") |
| 745 | 847 | ET.SubElement(primary, "source", file=str(drive)) |
| 746 | 848 | ET.SubElement(primary, "target", dev=details_profile["disk"][0], bus=details_profile["disk"][1]) |
| 849 | if details_profile["disk"][1] == "nvme": | |
| 850 | ET.SubElement(primary, "serial").text = "snowglobe" | |
| 747 | 851 | cd = ET.SubElement(devices, "disk", type="file", device="cdrom") |
| 748 | 852 | ET.SubElement(cd, "driver", name="qemu", type="raw") |
| 749 | 853 | if source and source.suffix.lower() == ".iso": |
| ... | ... | @@ -762,9 +866,22 @@ def create(spec): |
| 762 | 866 | windows_seed(directory, spec["username"]) |
| 763 | 867 | ET.SubElement(cd, "source", file=str(directory / "seed.iso")) |
| 764 | 868 | ET.SubElement(cd, "serial").text = "snowglobe-provision" |
| 765 | nic = ET.SubElement(devices, "interface", type="network") | |
| 766 | ET.SubElement(nic, "source", network="default") | |
| 767 | ET.SubElement(nic, "model", type=details_profile["network"]) | |
| 869 | if spec["platform"] == "macos": | |
| 870 | macos_seed(directory, spec["username"]) | |
| 871 | ET.SubElement(cd, "source", file=str(directory / "seed.iso")) | |
| 872 | qemu_ns = "http://libvirt.org/schemas/domain/qemu/1.0" | |
| 873 | ET.register_namespace("qemu", qemu_ns) | |
| 874 | arguments = ET.SubElement(root, "{" + qemu_ns + "}commandline") | |
| 875 | for argument in [ | |
| 876 | "-device", "isa-applesmc,osk=ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc", | |
| 877 | "-smbios", "type=2", "-netdev", "user,id=osxnet,restrict=yes,hostfwd=tcp:127.0.0.1:0-:22", | |
| 878 | "-device", details_profile["network"] + ",netdev=osxnet,bus=pcie.0,addr=0x12", | |
| 879 | ]: | |
| 880 | ET.SubElement(arguments, "{" + qemu_ns + "}arg", value=argument) | |
| 881 | else: | |
| 882 | nic = ET.SubElement(devices, "interface", type="network") | |
| 883 | ET.SubElement(nic, "source", network="default") | |
| 884 | ET.SubElement(nic, "model", type=details_profile["network"]) | |
| 768 | 885 | ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1") |
| 769 | 886 | video = {"type": details_profile.get("video", "vga"), "heads": "1", "primary": "yes"} |
| 770 | 887 | if video["type"] == "vga": |
| ... | ... | @@ -773,15 +890,22 @@ def create(spec): |
| 773 | 890 | ET.SubElement(devices, "input", type="tablet", bus="usb") |
| 774 | 891 | if keyboard := details_profile.get("keyboard"): |
| 775 | 892 | ET.SubElement(devices, "input", type="keyboard", bus=keyboard) |
| 776 | ET.SubElement(devices, "controller", type="usb", model=details_profile["usb"]) | |
| 893 | usb = ET.SubElement(devices, "controller", type="usb", index="0", model=details_profile["usb"]) | |
| 894 | if spec["platform"] == "macos": | |
| 895 | ET.SubElement(usb, "address", type="pci", domain="0x0000", bus="0x00", slot="0x1d", function="0x7") | |
| 896 | for index in range(3): | |
| 897 | controller = ET.SubElement(devices, "controller", type="usb", index="0", model=f"ich9-uhci{index + 1}") | |
| 898 | ET.SubElement(controller, "master", startport=str(index * 2)) | |
| 899 | ET.SubElement(controller, "address", type="pci", domain="0x0000", bus="0x00", slot="0x1d", function=hex(index), **({"multifunction": "on"} if index == 0 else {})) | |
| 777 | 900 | if details_profile.get("tpm"): |
| 778 | tpm = ET.SubElement(devices, "tpm", model="tpm-crb") | |
| 901 | tpm = ET.SubElement(devices, "tpm", model=details_profile["tpm"]) | |
| 779 | 902 | ET.SubElement(tpm, "backend", type="emulator", version="2.0") |
| 780 | 903 | if details_profile.get("balloon"): |
| 781 | 904 | ET.SubElement(devices, "memballoon", model=details_profile["balloon"]) |
| 782 | ET.SubElement(devices, "console", type="pty") | |
| 783 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0")) | |
| 784 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name=GUEST_CHANNEL)) | |
| 905 | if spec["platform"] != "macos": | |
| 906 | ET.SubElement(devices, "console", type="pty") | |
| 907 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0")) | |
| 908 | ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name=GUEST_CHANNEL)) | |
| 785 | 909 | xml = directory / "domain.xml" |
| 786 | 910 | xml.write_bytes(ET.tostring(root)) |
| 787 | 911 | signal.alarm(0) |
| ... | ... | @@ -789,7 +913,8 @@ def create(spec): |
| 789 | 913 | if spec["autostart"]: |
| 790 | 914 | virsh("autostart", name) |
| 791 | 915 | if spec["start"]: |
| 792 | ensure_network() | |
| 916 | if spec["platform"] != "macos": | |
| 917 | ensure_network() | |
| 793 | 918 | virsh("start", name) |
| 794 | 919 | except Exception: |
| 795 | 920 | signal.alarm(0) |
| ... | ... | @@ -863,7 +988,9 @@ def main(): |
| 863 | 988 | name = payload["name"] |
| 864 | 989 | ensure_name(name) |
| 865 | 990 | if payload["action"] == "start": |
| 866 | ensure_network() | |
| 991 | root = ET.fromstring(virsh("dumpxml", name, "--inactive")) | |
| 992 | if root.find("./devices/interface/source[@network='default']") is not None: | |
| 993 | ensure_network() | |
| 867 | 994 | virsh({"start": "start", "shutdown": "shutdown", "reboot": "reboot", "destroy": "destroy", "resume": "resume"}[payload["action"]], name) |
| 868 | 995 | elif action == "update": |
| 869 | 996 | name = payload["name"] |