authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:42:16-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 19:45:57-07:00
log442a2007a84dd5e45dfe33380a5d483bc0c1dc29
tree31cfe8a528b5bb43e8d8a27ad1930173f325266c
parentf0fafcafe57e96510289fea484792f68abe6dcd4
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Ship VM streaming, guest capture, hardware profiles, and project README rendering

Add declarative WebRTC/NVENC streaming with noVNC fallback and the Windows guest capture channel. Correct ARM input/video/serial and Windows 11 CPU/clock profiles. Render project READMEs in the browser with sanitized Markdown, alignment, and proportional images. Validated frontend check/build, 41 Rust tests, VM and guest-channel suites, and Windows guest builds for x86, x64, and ARM64 with cursor sanitizer tests. Assisted-by: gpt-6.1-sol

26 files changed, 1790 insertions(+), 265 deletions(-)

dashboard/src/vms.rs+21-4
......@@ -34,12 +34,29 @@ pub async fn console(request: Request, name: &str, serial: bool) -> Result<Respo
3434 .map_err(|_| Error::new(400, "Open the screen from the VM page."))?
3535 .max_message_size(1024 * 1024)
3636 .max_frame_size(1024 * 1024);
37 let target = json!({"operation":if serial { "vm.serial" } else { "vm.console" },"payload":{"name":name}});
37 let name = name.to_owned();
3838 Ok(upgrade
3939 .protocols(["binary"])
4040 .on_upgrade(move |mut socket| async move {
41 match host::open(target).await {
42 Ok((_, stream)) => bridge(socket, stream).await,
41 let stream = async {
42 if serial {
43 return host::open(json!({"operation":"vm.serial","payload":{"name":name}}))
44 .await
45 .map(|(_, stream)| stream);
46 }
47 let mut stream = tokio::net::UnixStream::connect(env(
48 "STUDIO_VM_SCREEN_SOCKET",
49 "/run/studio-vm-screen/screen.sock",
50 ))
51 .await?;
52 stream
53 .write_all(format!("{}\n", json!({"name":name})).as_bytes())
54 .await?;
55 Ok(stream)
56 }
57 .await;
58 match stream {
59 Ok(stream) => bridge(socket, stream).await,
4360 Err(error) => {
4461 let reason = if error.message.len() <= 120 {
4562 error.message
......@@ -89,9 +106,9 @@ async fn bridge(socket: WebSocket, stream: tokio::net::UnixStream) {
89106 while let Some(Ok(message)) = receive.next().await {
90107 match message {
91108 Message::Binary(data) => write.write_all(&data).await?,
109 Message::Text(data) => write.write_all(data.as_bytes()).await?,
92110 Message::Close(_) => break,
93111 Message::Ping(_) | Message::Pong(_) => {}
94 _ => break,
95112 }
96113 }
97114 Ok::<_, std::io::Error>(())
dashboard/vite.config.ts+3
......@@ -1,9 +1,12 @@
11import { defineConfig } from "vite";
22import solid from "vite-plugin-solid";
3import { createRequire } from "node:module";
4import { dirname, resolve } from "node:path";
35
46export default defineConfig({
57 root: "web",
68 plugins: [solid()],
9 resolve: { alias: { "@novnc/keyboard": resolve(dirname(createRequire(import.meta.url).resolve("@novnc/novnc")), "input/keyboard.js") } },
710 build: { outDir: "../dist", emptyOutDir: true },
811 server: {
912 host: true,
dashboard/web/components/VMConsole.tsx+222-36
......@@ -5,56 +5,200 @@ import Keyboard from "lucide-solid/icons/keyboard";
55import Clipboard from "lucide-solid/icons/clipboard";
66import Maximize from "lucide-solid/icons/maximize";
77import RefreshCw from "lucide-solid/icons/refresh-cw";
8import RemoteKeyboard from "@novnc/keyboard";
89
910export function VMConsole(props: { name: string; enabled: boolean; active: boolean; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) {
1011 const enabled = createMemo(() => props.enabled);
1112 let attempt = 0;
12 let screen!: HTMLDivElement;
13 let video!: HTMLVideoElement;
14 let cursorImage!: HTMLImageElement;
15 let cursorInvert!: HTMLImageElement;
1316 let panel!: HTMLDivElement;
14 let connection: import("@novnc/novnc").default | undefined;
17 let input: RTCDataChannel | undefined;
18 let disconnect = () => {};
19 let width = 0;
20 let height = 0;
21 let buttons = 0;
1522 let disposed = false;
23 let cursorFrames: { image: string; invert?: string; duration: number }[] = [];
24 let cursorFrame = 0;
25 let cursorTimer: number | undefined;
26 let cursorX = 0;
27 let cursorY = 0;
28 let pointerX = 0;
29 let pointerY = 0;
30 let cursorInside = false;
31 let cursorVisible = true;
32 const drawCursor = () => {
33 const frame = cursorFrames[cursorFrame];
34 const overlay = !!frame?.invert && cursorInside && cursorVisible;
35 cursorImage.style.display = cursorInvert.style.display = overlay ? "block" : "none";
36 if (!frame || !cursorVisible) { video.style.cursor = cursorVisible ? "default" : "none"; return; }
37 video.style.cursor = frame.invert ? (cursorInside ? "none" : "default") : `url(${frame.image}) ${cursorX} ${cursorY}, default`;
38 if (overlay) {
39 cursorImage.src = frame.image;
40 cursorInvert.src = frame.invert!;
41 cursorImage.style.transform = cursorInvert.style.transform = `translate(${pointerX - cursorX}px, ${pointerY - cursorY}px)`;
42 }
43 };
44 const animateCursor = () => {
45 const frame = cursorFrames[cursorFrame];
46 if (!frame) return;
47 cursorTimer = window.setTimeout(() => {
48 cursorFrame = (cursorFrame + 1) % cursorFrames.length;
49 drawCursor();
50 animateCursor();
51 }, frame.duration);
52 };
1653 const [state, setState] = createSignal<"Connecting…" | "Connected" | "Disconnected">("Connecting…");
1754 const connected = () => state() === "Connected";
1855 const [problem, setProblem] = createSignal("");
1956 const [clipboard, setClipboard] = createSignal("");
2057 const [showClipboard, setShowClipboard] = createSignal(false);
2158 const [tools, setTools] = createSignal<HTMLDivElement>();
59 const send = (message: object) => { if (input?.readyState === "open") input.send(JSON.stringify(message)); };
60 const release = () => { buttons = 0; send({ type: "release" }); };
2261 const connect = async () => {
2362 const current = ++attempt;
24 const previous = connection;
25 connection = undefined;
26 previous?.disconnect();
63 disconnect();
64 input = undefined;
2765 setProblem("");
2866 setState("Connecting…");
2967 try {
30 const { default: RFB } = await import("@novnc/novnc");
3168 if (disposed || !enabled() || current !== attempt) return;
69 if (!window.RTCPeerConnection) {
70 setProblem("This browser cannot stream VM screens. Open it in a current browser.");
71 setState("Disconnected");
72 return;
73 }
3274 const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/console`, location.href);
3375 url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
34 const rfb = new RFB(screen, url.href, { shared: true, wsProtocols: ["binary"] });
35 connection = rfb;
36 rfb.scaleViewport = true;
37 rfb.background = "#101014";
38 rfb.addEventListener("connect", () => {
39 if (connection !== rfb) return;
40 setState("Connected");
41 });
42 rfb.addEventListener("disconnect", () => {
43 if (connection !== rfb || disposed) return;
76 const socket = new WebSocket(url.href, ["binary"]);
77 socket.binaryType = "arraybuffer";
78 const keyboard = new RemoteKeyboard(video);
79 const focus = () => { if (input?.readyState === "open") keyboard.grab(); };
80 const blur = () => { keyboard.ungrab(); release(); };
81 video.addEventListener("focus", focus);
82 video.addEventListener("blur", blur);
83 let peer: RTCPeerConnection | undefined;
84 let pending = "";
85 let messages = Promise.resolve();
86 const decoder = new TextDecoder();
87 const candidates: RTCIceCandidateInit[] = [];
88 const signal = (message: object) => socket.send(JSON.stringify(message) + "\n");
89 const fail = (message: string) => {
90 if (current !== attempt || disposed) return;
4491 setState("Disconnected");
45 setProblem("The screen disconnected. Check that the VM is running, then reconnect.");
46 });
47 rfb.addEventListener("credentialsrequired", () => {
48 if (connection !== rfb || disposed) return;
49 setProblem("This screen requires a VNC password. Remove it in the VM's display settings, then reconnect.");
50 rfb.disconnect();
51 });
52 rfb.addEventListener("clipboard", (event: Event) => {
53 if (connection !== rfb || disposed) return;
54 setClipboard((event as CustomEvent<{ text: string }>).detail.text);
55 });
92 setProblem(message);
93 disconnect();
94 };
95 const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000);
96 disconnect = () => {
97 clearTimeout(timeout);
98 blur();
99 video.removeEventListener("focus", focus);
100 video.removeEventListener("blur", blur);
101 socket.onclose = null;
102 socket.close();
103 peer?.close();
104 video.onplaying = null;
105 video.srcObject = null;
106 clearTimeout(cursorTimer);
107 cursorFrames = [];
108 cursorFrame = 0;
109 cursorVisible = true;
110 cursorImage.style.display = cursorInvert.style.display = "none";
111 video.style.cursor = "default";
112 };
113 socket.onclose = () => fail("The screen disconnected. Check that the VM is running, then reconnect.");
114 socket.onerror = () => fail("Unable to connect the screen. Reconnect to try again.");
115 socket.onmessage = (event: MessageEvent<ArrayBuffer>) => {
116 pending += decoder.decode(event.data, { stream: true });
117 if (pending.length > 262144) { fail("The screen connection stopped. Reconnect to try again."); return; }
118 let end: number;
119 while ((end = pending.indexOf("\n")) !== -1) {
120 const line = pending.slice(0, end);
121 pending = pending.slice(end + 1);
122 messages = messages.then(async () => {
123 if (current !== attempt || disposed) return;
124 const message = JSON.parse(line);
125 if (message.type === "ready") {
126 peer = new RTCPeerConnection({ iceServers: message.iceServers });
127 const transceiver = peer.addTransceiver("video", { direction: "recvonly" });
128 const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => codec.mimeType === "video/H264");
129 if (codecs?.length) transceiver.setCodecPreferences(codecs);
130 input = peer.createDataChannel("input");
131 keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down });
132 input.onopen = () => { if (document.activeElement === video) focus(); };
133 peer.onicecandidate = ({ candidate }) => { if (candidate) signal({ type: "candidate", ...candidate.toJSON() }); };
134 peer.onconnectionstatechange = () => {
135 if (peer?.connectionState === "failed" || peer?.connectionState === "disconnected") {
136 fail("The screen disconnected. Reconnect to try again.");
137 }
138 };
139 peer.ontrack = ({ track }) => {
140 video.srcObject = new MediaStream([track]);
141 void video.play().catch(() => {
142 if (current === attempt && video.srcObject) fail("The browser couldn't play the screen. Reconnect to try again.");
143 });
144 };
145 await peer.setLocalDescription(await peer.createOffer());
146 signal({ type: "offer", sdp: peer.localDescription!.sdp });
147 } else if (message.type === "answer" && peer) {
148 await peer.setRemoteDescription({ type: "answer", sdp: message.sdp });
149 for (const candidate of candidates.splice(0)) await peer.addIceCandidate(candidate);
150 } else if (message.type === "candidate") {
151 if (peer?.remoteDescription) await peer.addIceCandidate(message);
152 else candidates.push(message);
153 } else if (message.type === "display") {
154 width = message.width;
155 height = message.height;
156 video.dataset.encoder = message.encoder;
157 video.dataset.source = message.source ?? "qemu";
158 } else if (message.type === "cursor") {
159 if (message.frame === 0) {
160 clearTimeout(cursorTimer);
161 cursorFrames = [];
162 cursorFrame = 0;
163 cursorX = message.x;
164 cursorY = message.y;
165 if (message.source !== "guest") cursorVisible = !!message.fallback || (!!message.width && !!message.height);
166 video.dataset.cursorSource = message.source;
167 }
168 if (!message.image) { drawCursor(); return; }
169 for (const url of [message.image, message.invert].filter(Boolean)) {
170 const image = new Image();
171 image.src = url;
172 await image.decode();
173 }
174 if (current !== attempt || disposed) return;
175 cursorFrames.push({ image: message.image, invert: message.invert, duration: message.duration });
176 if (message.frame === 0) drawCursor();
177 if (cursorFrames.length === message.frames && message.frames > 1) animateCursor();
178 } else if (message.type === "cursor-position") {
179 cursorVisible = message.visible;
180 drawCursor();
181 } else if (message.type === "clipboard") setClipboard(message.text);
182 else if (message.type === "error") {
183 if (message.fatal) fail(message.message);
184 else setProblem(message.message);
185 }
186 }).catch((failure) => {
187 console.error("VM screen connection", failure);
188 fail("The browser couldn't connect the screen. Reconnect to try again.");
189 });
190 }
191 };
192 video.onplaying = () => {
193 if (current !== attempt || disposed) return;
194 clearTimeout(timeout);
195 setState("Connected");
196 if (props.active) video.focus();
197 };
56198 } catch (failure) {
57 setProblem(`Unable to connect. ${reason(failure)}`);
199 console.error("VM screen connection", failure);
200 disconnect();
201 setProblem("The browser couldn't connect the screen. Reconnect to try again.");
58202 setState("Disconnected");
59203 }
60204 };
......@@ -62,21 +206,38 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
62206 if (enabled()) void connect();
63207 else {
64208 attempt++;
65 const previous = connection;
66 connection = undefined;
67 previous?.disconnect();
209 disconnect();
210 input = undefined;
68211 setState("Disconnected");
69212 setProblem("");
70213 }
71214 });
72 createEffect(() => { if (props.active && connected()) connection?.focus(); });
73 onCleanup(() => { disposed = true; connection?.disconnect(); });
215 createEffect(() => { if (props.active && connected()) video.focus(); else release(); });
216 onCleanup(() => { disposed = true; disconnect(); });
217 const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => {
218 if (!connected() || !width || !height) return;
219 const rect = video.getBoundingClientRect();
220 const scale = Math.min(rect.width / width, rect.height / height);
221 const x = Math.floor((event.clientX - rect.left - (rect.width - width * scale) / 2) / scale);
222 const y = Math.floor((event.clientY - rect.top - (rect.height - height * scale) / 2) / scale);
223 pointerX = event.clientX;
224 pointerY = event.clientY;
225 cursorInside = x >= 0 && y >= 0 && x < width && y < height;
226 drawCursor();
227 if ((event.type === "pointerdown" || event.type === "wheel") && (x < 0 || y < 0 || x >= width || y >= height)) return false;
228 send({ type: "pointer", x, y, buttons: mask });
229 return true;
230 };
231 const ctrlAltDel = () => {
232 for (const key of [0xffe3, 0xffe9, 0xffff]) send({ type: "key", key, down: true });
233 for (const key of [0xffff, 0xffe9, 0xffe3]) send({ type: "key", key, down: false });
234 };
74235 return <div class="vm-console" ref={panel}>
75236 <div class="vm-console-toolbar" ref={setTools}>
76237 {props.toolbar}
77238 <Show when={props.active}>
78239 <VMMenu label="Input">
79 <button disabled={!connected()} onClick={() => connection?.sendCtrlAltDel()}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button>
240 <button disabled={!connected()} onClick={ctrlAltDel}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button>
80241 <button disabled={!connected()} onClick={() => setShowClipboard(!showClipboard())}><Clipboard size={14} aria-hidden="true" />Clipboard</button>
81242 </VMMenu>
82243 <VMMenu label="View">
......@@ -93,12 +254,37 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
93254 <label class="field">clipboard<textarea class="search" rows="3" value={clipboard()} onInput={(event) => setClipboard(event.currentTarget.value)} /></label>
94255 <span class="hint">Clipboard sharing requires support in the guest</span>
95256 <div class="row">
96 <button class="button small" disabled={!connected()} onClick={() => connection?.clipboardPasteFrom(clipboard())}>send text</button>
257 <button class="button small" disabled={!connected()} onClick={() => send({ type: "clipboard", text: clipboard() })}>send text</button>
97258 <button class="button small" onClick={() => navigator.clipboard.writeText(clipboard()).catch((failure) => setProblem(reason(failure)))}>copy text</button>
98259 </div>
99260 </div>
100261 </Show>
101 <div class="vm-console-screen" hidden={!props.active || !props.enabled} ref={screen} />
262 <div class="vm-console-screen" hidden={!props.active || !props.enabled}>
263 <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen"
264 onContextMenu={(event) => event.preventDefault()}
265 onPointerMove={pointer}
266 onPointerLeave={() => { cursorInside = false; drawCursor(); }}
267 onPointerDown={(event) => {
268 event.preventDefault();
269 const mask = buttons | (event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0);
270 if (!pointer(event, mask)) return;
271 buttons = mask;
272 video.focus(); video.setPointerCapture(event.pointerId);
273 }}
274 onPointerUp={(event) => {
275 buttons &= ~(event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0);
276 pointer(event);
277 if (!event.buttons && video.hasPointerCapture(event.pointerId)) video.releasePointerCapture(event.pointerId);
278 }}
279 onPointerCancel={release}
280 onWheel={(event) => {
281 event.preventDefault();
282 const mask = event.deltaY ? (event.deltaY < 0 ? 8 : 16) : event.deltaX < 0 ? 32 : 64;
283 pointer(event, buttons | mask); pointer(event);
284 }} />
285 <img ref={cursorImage} class="vm-cursor" alt="" aria-hidden="true" />
286 <img ref={cursorInvert} class="vm-cursor vm-cursor-invert" alt="" aria-hidden="true" />
287 </div>
102288 <Show when={tools()}>{(toolbar) => props.content(toolbar())}</Show>
103289 <Show when={props.active && !props.enabled}><div class="empty"><p>The VM is off. Start it from the Machine menu.</p></div></Show>
104290 </div>;
dashboard/web/pages/VMs.css+3
......@@ -94,6 +94,9 @@
9494.vm-password { display: grid; gap: 6px; padding: 8px 10px; color: var(--muted); font-size: 11px; }
9595.vm-password input { width: 26ch; min-width: 0; padding: 5px 6px; color: var(--text); font: 12px monospace; }
9696.vm-console-screen { flex: 1; background: #101014; min-height: 0; }
97.vm-console-screen video { display: block; width: 100%; height: 100%; object-fit: contain; outline: none; touch-action: none; }
98.vm-cursor { display: none; position: fixed; left: 0; top: 0; pointer-events: none; }
99.vm-cursor-invert { mix-blend-mode: difference; }
97100.vm-console-error { padding: 8px 12px; margin: 0; font-size: 12px; }
98101.vm-clipboard { display: grid; gap: 8px; padding: 12px; }
99102@media (max-width: 640px) {
dashboard/web/types/novnc.d.ts+6-10
......@@ -1,12 +1,8 @@
1declare module "@novnc/novnc" {
2 export default class RFB extends EventTarget {
3 constructor(target: HTMLElement, url: string, options?: { shared?: boolean; wsProtocols?: string[] });
4 scaleViewport: boolean;
5 resizeSession: boolean;
6 background: string;
7 disconnect(): void;
8 focus(): void;
9 sendCtrlAltDel(): void;
10 clipboardPasteFrom(text: string): void;
1declare module "@novnc/keyboard" {
2 export default class Keyboard {
3 constructor(target: HTMLElement);
4 onkeyevent: (keysym: number, code: string, down: boolean) => void;
5 grab(): void;
6 ungrab(): void;
117 }
128}
guest/.gitignore created+1
......@@ -0,0 +1 @@
1build/
guest/DESIGN.md created+25
......@@ -0,0 +1,25 @@
1# Guest cursor stream
2
3The executable is freestanding C99. `cursor.c` contains pixel conversion and dirty-rectangle detection; `windows.c` owns Win32 capture and transport. Zig supplies the cross compiler and Windows headers, with no Zig or C runtime in the executable. x86 and x64 target Windows 7; ARM64 targets Windows 10. PE subsystem versions and imported DLLs are checked by `python3 guest/test.py`. Runtime validation currently uses Windows 10, not a Windows 7 fixture.
4
5Build with `python3 guest/build.py --arch x86_64` (also `x86` and `aarch64`). In an elevated PowerShell session inside the logged-in administrator's desktop, run `./install.ps1 -Binary ./build/snowglobe-guest-x86_64.exe`. The installer uses Task Scheduler COM APIs available to PowerShell 2, installs into protected Program Files, and starts an elevated task at that user's logon. `-Remove` removes that task and executable. Elevation is required by the VirtIO serial driver's device ACL; capture must run in the interactive session, not session 0. The guest needs a compatible VirtIO serial driver and the channel named in `protocol.json`. A standard-user launcher requires a privileged handle broker; it is not part of this first backend.
6
7`BitBlt` copies the primary desktop into a CPU DIB without drawing the cursor. RGB comparisons ignore the unused fourth byte. Unchanged pixels produce no screen packets; changed pixels produce one bounding rectangle. Cursor polling runs every 8 ms; screen capture runs at most 30 Hz. The host's existing encoder supplies WebRTC video. Secure desktops, RDP sessions that differ from the physical console, and capture failures return the viewer to QEMU capture.
8
9`GetCursorInfo` supplies the shape, visibility, and observed position. `GetIconInfo` and black/white `DrawIconEx` renders recover RGBA and binary XOR planes, including colored XOR. Nonbinary color XOR and unsupported cursor sizes explicitly fall back to the browser's default cursor, preserving video. Animation timing uses the optional `GetCursorFrameInfo` user32 export, resolved by name. Missing or unusable animation metadata falls back to a static frame. Animation starts a browser-local clock; Windows does not expose the current animation phase through this interface. Ordinary shapes use the browser cursor; XOR shapes use local layers with difference blending. Neither follows streamed positions.
10
11## Wire format
12
13`protocol.json` is the only home for packet IDs, field order, channel name, flags, and bounds. The build generates its private C header; the host reads that same schema. Each packet begins with the four-byte magic, a big-endian uint32 type, and a big-endian uint32 payload length. Fields listed in the schema are big-endian uint32 values.
14
15- Screen: tightly packed top-down BGRx rectangle rows following the listed fields. A connection, refresh request, resize, or resumed capture starts with a full frame.
16- Cursor: each frame is a uint32 duration in milliseconds, width × height RGBA pixels, then one XOR bit mask byte per pixel. The schema assigns the channel bits; each selected channel inverts the underlying browser pixel. Zero dimensions and zero frames mean unsupported shape/default cursor.
17- Pointer: coordinates use signed two's-complement values in the uint32 fields. These are observations, not a cursor hit map.
18- Suspend and heartbeat have empty payloads. A heartbeat arrives at least once per second; four seconds without a packet falls back to QEMU.
19- Refresh is the only host-to-guest message and has an empty payload. The agent validates the entire request. The host requests it on every connection, including reconnects that reuse an open guest port.
20
21The host broker validates a running VM's libvirt-owned channel path. One guest reader fans out to that VM's viewers. Guest bytes are untrusted and bounded before decoding; viewer buffers are bounded independently. No guest network listener or GPU is required.
22
23## Later backends and surfaces
24
25Keep the core independent of an OS and add real platform implementations when their capture and cursor APIs can be tested. X11 cursor notifications, Wayland's compositor/portal permissions, and macOS capture permissions need separate backend decisions, not empty adapters. The next Windows experiment can enumerate HWNDs and attach full surfaces, geometry, stacking, and dirty regions to this transport; occlusion, layered windows, protected content, DPI, and secure desktops need explicit behavior. A window rectangle does not imply a uniform cursor: application hit testing can change the shape anywhere inside it. Region prediction should be based on validated application information or observations with invalidation, not fabricated bounding boxes.
guest/build.py created+36
......@@ -0,0 +1,36 @@
1#!/usr/bin/env python3
2import argparse
3import json
4from pathlib import Path
5import re
6import subprocess
7
8
9parser = argparse.ArgumentParser()
10parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"], default="x86_64")
11parser.add_argument("--output", type=Path, default=Path(__file__).parent / "build")
12args = parser.parse_args()
13source = Path(__file__).resolve().parent
14args.output.mkdir(parents=True, exist_ok=True)
15schema = json.loads((source / "protocol.json").read_text())
16header = ["#ifndef SG_PROTOCOL_H", "#define SG_PROTOCOL_H"]
17header += [f"#define SG_MAX_{key.upper()} {value}u" for key, value in schema["limits"].items()]
18header += [f"#define SG_XOR_{key.upper()} {value}u" for key, value in schema["xor_bits"].items()]
19for name, packet in schema["packets"].items():
20 header += [f"#define SG_{name.upper()} {packet['id']}u", f"#define SG_{name.upper()}_WORDS {len(packet['fields'])}u"]
21 header += [f"#define SG_{name.upper()}_{field.upper()} {index}u" for index, field in enumerate(packet["fields"])]
22header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"]
23(args.output / "protocol.h").write_text("\n".join(header) + "\n")
24subsystem = "10.0" if args.arch == "aarch64" else "6.1"
25environment = subprocess.check_output(["zig", "env"], text=True)
26match = re.search(r'\.lib_dir\s*=\s*"([^"]+)"', environment)
27library = Path(match[1] if match else json.loads(environment)["lib_dir"])
28subprocess.run([
29 "zig", "cc", "-target", args.arch + ("-windows.win10-gnu" if args.arch == "aarch64" else "-windows.win7-gnu"), "-std=c99", "-Os", "-Wall", "-Wextra", "-Werror",
30 "-ffreestanding", "-fno-stack-protector", "-nostdlib", "-D_WIN32_WINNT=0x0601", "-DWINVER=0x0601",
31 "-isystem", str(library / "libc/include/any-windows-any"),
32 "-I", str(args.output), str(source / "cursor.c"), str(source / "windows.c"),
33 "-lkernel32", "-luser32", "-lgdi32", "-Wl,--entry,mainCRTStartup", "-Wl,--subsystem,windows",
34 "-Wl,--major-subsystem-version," + subsystem.split(".")[0], "-Wl,--minor-subsystem-version," + subsystem.split(".")[1],
35 "-o", str(args.output / ("snowglobe-guest-" + args.arch + ".exe")),
36], check=True)
guest/cursor.c created+70
......@@ -0,0 +1,70 @@
1#include "cursor.h"
2
3void sg_word(uint8_t *bytes, uint32_t value) {
4 bytes[0] = (uint8_t)(value >> 24);
5 bytes[1] = (uint8_t)(value >> 16);
6 bytes[2] = (uint8_t)(value >> 8);
7 bytes[3] = (uint8_t)value;
8}
9
10int sg_difference(const uint8_t *before, const uint8_t *after, uint32_t width, uint32_t height, struct sg_rect *rect) {
11 uint32_t x, y, left = width, top = height, right = 0, bottom = 0;
12 if (!width || !height || width > SG_MAX_DIMENSION || height > SG_MAX_DIMENSION || width * height > SG_MAX_PIXELS) return -1;
13 for (y = 0; y < height; ++y) {
14 for (x = 0; x < width; ++x) {
15 size_t pixel = ((size_t)y * width + x) * 4;
16 if (before[pixel] == after[pixel] && before[pixel + 1] == after[pixel + 1] && before[pixel + 2] == after[pixel + 2]) continue;
17 if (x < left) left = x;
18 if (y < top) top = y;
19 if (x >= right) right = x + 1;
20 if (y >= bottom) bottom = y + 1;
21 }
22 }
23 if (left == width) return 0;
24 rect->x = left; rect->y = top; rect->width = right - left; rect->height = bottom - top;
25 return 1;
26}
27
28int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t *invert, size_t pixels) {
29 size_t i;
30 for (i = 0; i < pixels; ++i) {
31 int difference = (int)white[i * 4] - black[i * 4];
32 int green = (int)white[i * 4 + 1] - black[i * 4 + 1];
33 int red = (int)white[i * 4 + 2] - black[i * 4 + 2];
34 unsigned alpha;
35 invert[i] = 0;
36 if (difference < 0 || green < 0 || red < 0 || green - difference > 1 || difference - green > 1 || red - difference > 1 || difference - red > 1) {
37 unsigned channel;
38 for (channel = 0; channel < 3; ++channel) {
39 uint8_t value = black[i * 4 + channel];
40 if ((value != 0 && value != 255) || (value ^ white[i * 4 + channel]) != 255) return 0;
41 }
42 invert[i] = (black[i * 4 + 2] ? SG_XOR_RED : 0) | (black[i * 4 + 1] ? SG_XOR_GREEN : 0) | (black[i * 4] ? SG_XOR_BLUE : 0);
43 alpha = 0;
44 } else alpha = 255u - (unsigned)((difference + green + red + 1) / 3);
45 rgba[i * 4 + 3] = (uint8_t)alpha;
46 {
47 unsigned channel;
48 for (channel = 0; channel < 3; ++channel) {
49 unsigned value = alpha ? (black[i * 4 + 2 - channel] * 255u + alpha / 2) / alpha : 0;
50 rgba[i * 4 + channel] = (uint8_t)(value > 255 ? 255 : value);
51 }
52 }
53 }
54 return 1;
55}
56
57void *memcpy(void *destination, const void *source, size_t length) {
58 uint8_t *out = destination;
59 const uint8_t *in = source;
60 size_t i;
61 for (i = 0; i < length; ++i) out[i] = in[i];
62 return destination;
63}
64
65void *memset(void *destination, int value, size_t length) {
66 uint8_t *out = destination;
67 size_t i;
68 for (i = 0; i < length; ++i) out[i] = (uint8_t)value;
69 return destination;
70}
guest/cursor.h created+11
......@@ -0,0 +1,11 @@
1#ifndef SG_CURSOR_H
2#define SG_CURSOR_H
3#include <stddef.h>
4#include <stdint.h>
5#include "protocol.h"
6
7struct sg_rect { uint32_t x, y, width, height; };
8int sg_difference(const uint8_t *before, const uint8_t *after, uint32_t width, uint32_t height, struct sg_rect *rect);
9int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t *invert, size_t pixels);
10void sg_word(uint8_t *bytes, uint32_t value);
11#endif
guest/install.ps1 created+38
......@@ -0,0 +1,38 @@
1param([string]$Binary, [switch]$Remove)
2$ErrorActionPreference = 'Stop'
3$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
4$principal = New-Object Security.Principal.WindowsPrincipal($identity)
5if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this installer as administrator to access the VirtIO serial port.' }
6if ((Get-WmiObject Win32_ComputerSystem).UserName -ne $identity.Name) { throw 'Run this installer from the signed-in administrator account on the VM desktop.' }
7$directory = Join-Path $env:ProgramFiles 'Snowglobe\Guest'
8$destination = Join-Path $directory 'snowglobe-guest.exe'
9$service = New-Object -ComObject Schedule.Service
10$service.Connect()
11$folder = $service.GetFolder('\')
12$name = 'SnowglobeGuest-' + $identity.User.Value
13if ($Remove) {
14 try { $folder.GetTask($name).Stop(0); $folder.DeleteTask($name, 0) } catch { if (Test-Path $destination) { throw } }
15 $used = $false
16 foreach ($task in $folder.GetTasks(1)) {
17 foreach ($action in $task.Definition.Actions) { if ($action.Path -eq $destination) { $used = $true } }
18 }
19 if (!$used -and (Test-Path $destination)) { Remove-Item $destination }
20 return
21}
22if (!$Binary) { throw 'Pass the path to the guest executable with -Binary.' }
23Get-WmiObject Win32_Process -Filter "name='snowglobe-guest.exe'" | Where-Object { $_.ExecutablePath -eq $destination } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
24New-Item -ItemType Directory -Path $directory -Force | Out-Null
25Copy-Item -LiteralPath $Binary -Destination $destination -Force
26$task = $service.NewTask(0)
27$task.Principal.UserId = $identity.User.Value
28$task.Principal.LogonType = 3
29$task.Principal.RunLevel = 1
30$task.Settings.ExecutionTimeLimit = 'PT0S'
31$task.Settings.DisallowStartIfOnBatteries = $false
32$task.Settings.StopIfGoingOnBatteries = $false
33$trigger = $task.Triggers.Create(9)
34$trigger.UserId = $identity.User.Value
35$action = $task.Actions.Create(0)
36$action.Path = $destination
37$registered = $folder.RegisterTaskDefinition($name, $task, 6, $identity.User.Value, $null, 3, $null)
38$registered.Run($null) | Out-Null
guest/protocol.json created+14
......@@ -0,0 +1,14 @@
1{
2 "magic": "SGV1",
3 "channel": "net.paperclover.snowglobe.0",
4 "xor_bits": {"red": 1, "green": 2, "blue": 4},
5 "limits": {"dimension": 4096, "pixels": 8294400, "cursor_size": 128, "cursor_frames": 64, "packet_bytes": 33554456},
6 "packets": {
7 "screen": {"id": 1, "fields": ["width", "height", "x", "y", "rect_width", "rect_height"]},
8 "cursor": {"id": 2, "fields": ["id", "width", "height", "hot_x", "hot_y", "frames"]},
9 "pointer": {"id": 3, "fields": ["id", "x", "y", "visible"]},
10 "suspend": {"id": 4, "fields": []},
11 "refresh": {"id": 5, "fields": []},
12 "heartbeat": {"id": 6, "fields": []}
13 }
14}
guest/test.py created+36
......@@ -0,0 +1,36 @@
1#!/usr/bin/env python3
2from pathlib import Path
3import struct
4import subprocess
5import tempfile
6
7source = Path(__file__).resolve().parent
8for arch in ['x86', 'x86_64', 'aarch64']:
9 subprocess.run(['python3', str(source / 'build.py'), '--arch', arch], check=True)
10 data = (source / 'build' / ('snowglobe-guest-' + arch + '.exe')).read_bytes()
11 pe = struct.unpack_from('<I', data, 60)[0]
12 assert data[pe:pe + 4] == b'PE\0\0'
13 machine, sections, _, _, _, optional_size = struct.unpack_from('<HHIIIH', data, pe + 4)
14 optional = pe + 24
15 assert machine == {'x86': 0x14c, 'x86_64': 0x8664, 'aarch64': 0xaa64}[arch]
16 assert struct.unpack_from('<HH', data, optional + 48) == ((10, 0) if arch == 'aarch64' else (6, 1))
17 table = optional + optional_size
18 def offset(rva):
19 for index in range(sections):
20 size, start, raw_size, raw = struct.unpack_from('<IIII', data, table + 40 * index + 8)
21 if start <= rva < start + max(size, raw_size): return raw + rva - start
22 raise AssertionError('unmapped PE address')
23 directory = optional + (96 if arch == 'x86' else 112)
24 imports = offset(struct.unpack_from('<I', data, directory + 8)[0])
25 libraries = set()
26 while any(data[imports:imports + 20]):
27 name = offset(struct.unpack_from('<I', data, imports + 12)[0])
28 libraries.add(data[name:data.index(0, name)].decode().lower())
29 imports += 20
30 assert libraries == {'kernel32.dll', 'user32.dll', 'gdi32.dll'}, libraries
31 print(arch, len(data), 'bytes; system DLLs only; minimum subsystem verified')
32with tempfile.TemporaryDirectory() as temporary:
33 binary = Path(temporary) / 'cursor-tests'
34 subprocess.run(['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', '-ffreestanding', '-fsanitize=address,undefined', '-I', str(source / 'build'), str(source / 'cursor.c'), str(source / 'tests.c'), '-o', str(binary)], check=True)
35 subprocess.run([str(binary)], check=True)
36print('cursor alpha, inversion, dirty rectangles, and bounds passed')
guest/tests.c created+32
......@@ -0,0 +1,32 @@
1#include "cursor.h"
2#include <assert.h>
3
4int main(void) {
5 uint8_t before[4 * 4 * 3] = {0}, after[4 * 4 * 3] = {0};
6 struct sg_rect rect;
7 uint8_t black[] = {0,0,0,0, 255,255,255,0, 0,0,0,0, 20,40,60,0};
8 uint8_t white[] = {255,255,255,0, 0,0,0,0, 0,0,0,0, 147,167,187,0};
9 uint8_t rgba[16], invert[4], word[4];
10 assert(sg_difference(before, after, 4, 3, &rect) == 0);
11 after[3] = 255;
12 assert(sg_difference(before, after, 4, 3, &rect) == 0);
13 after[(1 * 4 + 1) * 4] = 1;
14 after[(2 * 4 + 3) * 4 + 2] = 2;
15 assert(sg_difference(before, after, 4, 3, &rect) == 1);
16 assert(rect.x == 1 && rect.y == 1 && rect.width == 3 && rect.height == 2);
17 assert(sg_difference(before, after, 0, 3, &rect) == -1);
18 assert(sg_difference(before, after, 65535, 65535, &rect) == -1);
19 assert(sg_rgba(black, white, rgba, invert, 4));
20 assert(rgba[3] == 0 && invert[0] == 0);
21 assert(rgba[7] == 0 && invert[1] == (SG_XOR_RED | SG_XOR_GREEN | SG_XOR_BLUE));
22 assert(rgba[11] == 255 && rgba[8] == 0 && invert[2] == 0);
23 assert(rgba[12] == 120 && rgba[13] == 80 && rgba[14] == 40 && rgba[15] == 128 && invert[3] == 0);
24 black[0] = 0; black[1] = 0; black[2] = 255;
25 white[0] = 255; white[1] = 255; white[2] = 0;
26 assert(sg_rgba(black, white, rgba, invert, 1) && invert[0] == SG_XOR_RED && rgba[3] == 0);
27 black[2] = 127; white[2] = 128;
28 assert(!sg_rgba(black, white, rgba, invert, 1));
29 sg_word(word, 0x12345678);
30 assert(word[0] == 0x12 && word[1] == 0x34 && word[2] == 0x56 && word[3] == 0x78);
31 return 0;
32}
guest/windows.c created+241
......@@ -0,0 +1,241 @@
1#define WIN32_LEAN_AND_MEAN
2#define UNICODE
3#define _UNICODE
4#include <windows.h>
5#include "cursor.h"
6
7typedef HCURSOR (WINAPI *cursor_frame_fn)(HCURSOR, DWORD, DWORD, DWORD *, DWORD *);
8static cursor_frame_fn cursor_frame;
9static HANDLE output;
10static HANDLE heap;
11static OVERLAPPED sending;
12
13static int write_bytes(const void *data, DWORD length) {
14 const uint8_t *bytes = data;
15 DWORD written;
16 while (length) {
17 ResetEvent(sending.hEvent);
18 if (!WriteFile(output, bytes, length, &written, &sending)) {
19 if (GetLastError() != ERROR_IO_PENDING) return 0;
20 if (WaitForSingleObject(sending.hEvent, 3000) != WAIT_OBJECT_0) {
21 CancelIo(output); WaitForSingleObject(sending.hEvent, INFINITE); return 0;
22 }
23 }
24 if (!GetOverlappedResult(output, &sending, &written, FALSE)) return 0;
25 if (!written) return 0;
26 bytes += written; length -= written;
27 }
28 return 1;
29}
30
31static int packet(uint32_t type, uint32_t length, const uint32_t *words, uint32_t count) {
32 uint8_t header[12], number[4];
33 uint32_t i;
34 header[0] = SG_MAGIC[0]; header[1] = SG_MAGIC[1]; header[2] = SG_MAGIC[2]; header[3] = SG_MAGIC[3];
35 sg_word(header + 4, type); sg_word(header + 8, length);
36 if (!write_bytes(header, sizeof(header))) return 0;
37 for (i = 0; i < count; ++i) {
38 sg_word(number, words[i]);
39 if (!write_bytes(number, sizeof(number))) return 0;
40 }
41 return 1;
42}
43
44static int capture_cursor(HCURSOR cursor, uint32_t id) {
45 ICONINFO icon = {0};
46 BITMAP bitmap;
47 BITMAPINFO info = {0};
48 HDC dc = NULL;
49 HBITMAP dib = NULL, previous = NULL;
50 uint8_t *bits = NULL, *black = NULL, *frames = NULL;
51 uint32_t width, height, count = 1, step, words[SG_CURSOR_WORDS] = {0};
52 DWORD rate = 6, total = 1;
53 size_t pixels, frame_bytes;
54 int success = -1;
55 if (!GetIconInfo(cursor, &icon)) return -1;
56 if (!GetObjectW(icon.hbmColor ? icon.hbmColor : icon.hbmMask, sizeof(bitmap), &bitmap)) goto done;
57 width = (uint32_t)bitmap.bmWidth;
58 height = (uint32_t)(icon.hbmColor ? bitmap.bmHeight : bitmap.bmHeight / 2);
59 if (!width || !height || width > SG_MAX_CURSOR_SIZE || height > SG_MAX_CURSOR_SIZE || icon.xHotspot >= width || icon.yHotspot >= height) goto done;
60 if (cursor_frame && cursor_frame(cursor, 0, 0, &rate, &total) && total > 1 && total <= SG_MAX_CURSOR_FRAMES) count = total;
61 pixels = (size_t)width * height;
62 frame_bytes = 4 + pixels * 5;
63 frames = HeapAlloc(heap, 0, frame_bytes * count);
64 black = HeapAlloc(heap, 0, pixels * 4);
65 dc = CreateCompatibleDC(NULL);
66 info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); info.bmiHeader.biWidth = (LONG)width;
67 info.bmiHeader.biHeight = -(LONG)height; info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32;
68 dib = CreateDIBSection(dc, &info, DIB_RGB_COLORS, (void **)&bits, NULL, 0);
69 if (!frames || !black || !dc || !dib) goto done;
70 previous = SelectObject(dc, dib);
71 for (step = 0; step < count; ++step) {
72 uint8_t *frame = frames + frame_bytes * step;
73 size_t i;
74 HCURSOR image = cursor;
75 rate = 6;
76 if (count > 1) {
77 image = cursor_frame(cursor, 0, step, &rate, &total);
78 if (!image || total != count || !rate || rate > 3600) { image = cursor; count = 1; rate = 6; }
79 }
80 sg_word(frame, (rate * 1000u + 30u) / 60u);
81 for (i = 0; i < pixels * 4; ++i) bits[i] = 0;
82 if (!DrawIconEx(dc, 0, 0, image, (int)width, (int)height, 0, NULL, DI_NORMAL)) goto done;
83 GdiFlush();
84 for (i = 0; i < pixels * 4; ++i) black[i] = bits[i];
85 for (i = 0; i < pixels * 4; ++i) bits[i] = 255;
86 if (!DrawIconEx(dc, 0, 0, image, (int)width, (int)height, 0, NULL, DI_NORMAL)) goto done;
87 GdiFlush();
88 if (!sg_rgba(black, bits, frame + 4, frame + 4 + pixels * 4, pixels)) goto done;
89 }
90 words[SG_CURSOR_ID] = id; words[SG_CURSOR_WIDTH] = width; words[SG_CURSOR_HEIGHT] = height;
91 words[SG_CURSOR_HOT_X] = icon.xHotspot; words[SG_CURSOR_HOT_Y] = icon.yHotspot; words[SG_CURSOR_FRAMES] = count;
92 success = packet(SG_CURSOR, SG_CURSOR_WORDS * 4 + (uint32_t)(frame_bytes * count), words, SG_CURSOR_WORDS) && write_bytes(frames, (DWORD)(frame_bytes * count));
93done:
94 if (previous) SelectObject(dc, previous);
95 if (dib) DeleteObject(dib);
96 if (dc) DeleteDC(dc);
97 if (icon.hbmColor) DeleteObject(icon.hbmColor);
98 if (icon.hbmMask) DeleteObject(icon.hbmMask);
99 if (black) HeapFree(heap, 0, black);
100 if (frames) HeapFree(heap, 0, frames);
101 return success;
102}
103
104static void stream(DWORD session) {
105 HDC screen = GetDC(NULL), dc = CreateCompatibleDC(screen);
106 HBITMAP dib = NULL, previous = NULL;
107 uint8_t *pixels = NULL, *before = NULL;
108 uint32_t width = 0, height = 0, id = 0;
109 HCURSOR last_cursor = NULL;
110 POINT last_point = {-1, -1};
111 DWORD last_flags = ~0u, captured = GetTickCount() - 34;
112 DWORD heartbeat = GetTickCount(), received = 0;
113 OVERLAPPED receiving = {0};
114 uint8_t request[12];
115 int pending = 0;
116 int suspended = 0, initial = 1;
117 receiving.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
118 if (!screen || !dc || !receiving.hEvent) goto done;
119 for (;;) {
120 if (session != WTSGetActiveConsoleSessionId()) goto done;
121 CURSORINFO cursor = {sizeof(CURSORINFO), 0, NULL, {0, 0}};
122 HDESK desktop;
123 WCHAR name[32];
124 DWORD length;
125 DWORD amount = 0;
126 if (!pending) {
127 ResetEvent(receiving.hEvent);
128 if (!ReadFile(output, request + received, (DWORD)sizeof(request) - received, &amount, &receiving)) {
129 if (GetLastError() != ERROR_IO_PENDING) goto done;
130 pending = 1;
131 } else if (!GetOverlappedResult(output, &receiving, &amount, FALSE)) goto done;
132 } else if (GetOverlappedResult(output, &receiving, &amount, FALSE)) pending = 0;
133 else if (GetLastError() != ERROR_IO_INCOMPLETE) goto done;
134 if (!pending) {
135 if (!amount) goto done;
136 received += amount;
137 if (received == sizeof(request)) {
138 uint8_t expected[12] = {SG_MAGIC[0], SG_MAGIC[1], SG_MAGIC[2], SG_MAGIC[3], 0,0,0,0, 0,0,0,0};
139 uint32_t i;
140 sg_word(expected + 4, SG_REFRESH);
141 for (i = 0; i < sizeof(request); ++i) if (request[i] != expected[i]) goto done;
142 received = 0; initial = 1; last_cursor = NULL; last_flags = ~0u;
143 }
144 }
145 if ((DWORD)(GetTickCount() - heartbeat) >= 1000) {
146 if (!packet(SG_HEARTBEAT, 0, NULL, 0)) goto done;
147 heartbeat = GetTickCount();
148 }
149 desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
150 int active = desktop && GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &length) && name[0] == L'D' && name[1] == L'e' && name[2] == L'f' && name[3] == L'a' && name[4] == L'u' && name[5] == L'l' && name[6] == L't' && !name[7];
151 if (desktop) CloseDesktop(desktop);
152 if (!active || !GetCursorInfo(&cursor)) {
153 if (!suspended && !packet(SG_SUSPEND, 0, NULL, 0)) goto done;
154 suspended = 1; initial = 1; last_cursor = NULL; last_flags = ~0u;
155 Sleep(100); continue;
156 }
157 int changed = cursor.hCursor && cursor.hCursor != last_cursor;
158 if (changed) ++id;
159 if (changed || cursor.flags != last_flags || cursor.ptScreenPos.x != last_point.x || cursor.ptScreenPos.y != last_point.y) {
160 uint32_t words[SG_POINTER_WORDS] = {0};
161 words[SG_POINTER_ID] = id; words[SG_POINTER_X] = (uint32_t)cursor.ptScreenPos.x;
162 words[SG_POINTER_Y] = (uint32_t)cursor.ptScreenPos.y; words[SG_POINTER_VISIBLE] = (cursor.flags & CURSOR_SHOWING) ? 1 : 0;
163 if (!packet(SG_POINTER, sizeof(words), words, SG_POINTER_WORDS)) goto done;
164 last_flags = cursor.flags; last_point = cursor.ptScreenPos;
165 }
166 if (changed) {
167 int result = capture_cursor(cursor.hCursor, id);
168 if (!result) goto done;
169 if (result < 0) {
170 uint32_t words[SG_CURSOR_WORDS] = {0};
171 words[SG_CURSOR_ID] = id;
172 if (!packet(SG_CURSOR, sizeof(words), words, SG_CURSOR_WORDS)) goto done;
173 }
174 last_cursor = cursor.hCursor;
175 }
176 if ((DWORD)(GetTickCount() - captured) >= 33) {
177 uint32_t w = (uint32_t)GetSystemMetrics(SM_CXSCREEN), h = (uint32_t)GetSystemMetrics(SM_CYSCREEN), y;
178 struct sg_rect rect;
179 uint32_t words[SG_SCREEN_WORDS] = {0};
180 if (!w || !h || w > SG_MAX_DIMENSION || h > SG_MAX_DIMENSION || w * h > SG_MAX_PIXELS) goto done;
181 if (w != width || h != height) {
182 BITMAPINFO info = {0};
183 if (previous) { SelectObject(dc, previous); previous = NULL; }
184 if (dib) DeleteObject(dib);
185 if (before) HeapFree(heap, 0, before);
186 before = HeapAlloc(heap, 0, (size_t)w * h * 4);
187 info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); info.bmiHeader.biWidth = (LONG)w;
188 info.bmiHeader.biHeight = -(LONG)h; info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32;
189 dib = CreateDIBSection(dc, &info, DIB_RGB_COLORS, (void **)&pixels, NULL, 0);
190 if (!before || !dib) goto done;
191 previous = SelectObject(dc, dib); width = w; height = h; initial = 1;
192 }
193 if (!BitBlt(dc, 0, 0, (int)width, (int)height, screen, 0, 0, SRCCOPY | CAPTUREBLT)) {
194 if (!suspended && !packet(SG_SUSPEND, 0, NULL, 0)) goto done;
195 suspended = 1; initial = 1; Sleep(100); continue;
196 }
197 GdiFlush();
198 if (initial) { rect.x = 0; rect.y = 0; rect.width = width; rect.height = height; }
199 else if (!sg_difference(before, pixels, width, height, &rect)) { captured = GetTickCount(); Sleep(8); continue; }
200 words[SG_SCREEN_WIDTH] = width; words[SG_SCREEN_HEIGHT] = height;
201 words[SG_SCREEN_X] = rect.x; words[SG_SCREEN_Y] = rect.y;
202 words[SG_SCREEN_RECT_WIDTH] = rect.width; words[SG_SCREEN_RECT_HEIGHT] = rect.height;
203 if (!packet(SG_SCREEN, sizeof(words) + rect.width * rect.height * 4, words, SG_SCREEN_WORDS)) goto done;
204 for (y = rect.y; y < rect.y + rect.height; ++y) {
205 size_t offset = ((size_t)y * width + rect.x) * 4, i;
206 if (!write_bytes(pixels + offset, rect.width * 4)) goto done;
207 for (i = 0; i < rect.width * 4; ++i) before[offset + i] = pixels[offset + i];
208 }
209 initial = 0; suspended = 0; captured = GetTickCount();
210 }
211 Sleep(8);
212 }
213done:
214 CancelIo(output);
215 if (pending) WaitForSingleObject(receiving.hEvent, INFINITE);
216 if (receiving.hEvent) CloseHandle(receiving.hEvent);
217 if (previous) SelectObject(dc, previous);
218 if (dib) DeleteObject(dib);
219 if (before) HeapFree(heap, 0, before);
220 if (dc) DeleteDC(dc);
221 if (screen) ReleaseDC(NULL, screen);
222}
223
224void mainCRTStartup(void) {
225 FARPROC symbol;
226 DWORD session;
227 if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) ExitProcess(1);
228 heap = GetProcessHeap();
229 sending.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
230 if (!sending.hEvent) ExitProcess(1);
231 SetProcessDPIAware();
232 symbol = GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetCursorFrameInfo");
233 /* This optional user32 export supplies ANI step timing; static capture uses only documented APIs. */
234 cursor_frame = (cursor_frame_fn)symbol;
235 for (;;) {
236 if (session != WTSGetActiveConsoleSessionId()) { Sleep(1000); continue; }
237 output = CreateFileW(L"\\\\.\\Global\\" SG_CHANNEL, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
238 if (output != INVALID_HANDLE_VALUE) { stream(session); CloseHandle(output); }
239 Sleep(1000);
240 }
241}
nixos/configuration.nix+39-3
......@@ -6,9 +6,11 @@ let
66 proxyToken = "/var/lib/studio/dashboard-proxy.token";
77 hostTools = lib.fileset.toSource {
88 root = ../.;
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ];
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ];
1010 };
1111 nativePkl = pkgs.callPackage ./pkl.nix { };
12 screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]);
13 screenPlugins = (with pkgs.gst_all_1; [ (lib.getLib gstreamer) gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly ]) ++ [ (lib.getLib pkgs.libnice) ];
1214 secureOvmf = pkgs.OVMF.override {
1315 secureBoot = true;
1416 msVarsTemplate = true;
......@@ -21,6 +23,7 @@ in
2123 networking.firewall = {
2224 enable = true;
2325 allowedTCPPorts = [ 22 ];
26 allowedUDPPortRanges = [{ from = 50000; to = 50031; }];
2427 interfaces.podman0.allowedTCPPorts = [ 443 internalPort 10428 15432 18428 ];
2528 interfaces.podman0.allowedTCPPortRanges = [{ from = 20000; to = 32000; }];
2629 interfaces.tailscale0.allowedTCPPorts = [ 80 443 445 8081 8082 ];
......@@ -118,6 +121,38 @@ in
118121 isSystemUser = true;
119122 group = "studio-dashboard";
120123 };
124 systemd.services.studio-vm-screen = {
125 wantedBy = [ "multi-user.target" ];
126 wants = [ "studio-host.service" ];
127 after = [ "studio-host.service" ];
128 environment = {
129 GST_PLUGIN_SYSTEM_PATH_1_0 = lib.makeSearchPath "lib/gstreamer-1.0" screenPlugins;
130 GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" (map lib.getLib ([ pkgs.gst_all_1.gstreamer ] ++ screenPlugins));
131 LD_LIBRARY_PATH = "/run/opengl-driver/lib";
132 STUDIO_VM_GUEST_PROTOCOL = toString ../guest/protocol.json;
133 };
134 serviceConfig = {
135 ExecStart = "${screenPython}/bin/python3 ${../tools/vm-screen.py}";
136 User = "studio-dashboard";
137 Group = "studio-dashboard";
138 RuntimeDirectory = "studio-vm-screen";
139 RuntimeDirectoryMode = "0700";
140 RuntimeDirectoryPreserve = "yes";
141 UMask = "0077";
142 ProtectSystem = "strict";
143 ProtectHome = true;
144 PrivateTmp = true;
145 NoNewPrivileges = true;
146 CapabilityBoundingSet = "";
147 MemoryMax = "2G";
148 TasksMax = 128;
149 DevicePolicy = "closed";
150 DeviceAllow = lib.optional (builtins.elem "nvidia" config.services.xserver.videoDrivers) "char-nvidia* rw";
151 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
152 Restart = "always";
153 RestartSec = 2;
154 };
155 };
121156 systemd.services.studio-host = {
122157 wantedBy = [ "multi-user.target" ];
123158 wants = [ "podman.socket" ];
......@@ -181,8 +216,8 @@ in
181216 in {
182217 inherit environment;
183218 wantedBy = [ "multi-user.target" ];
184 wants = [ "nomad.service" "studio-router.service" "studio-host.service" ];
185 after = [ "nomad.service" "studio-host.service" ];
219 wants = [ "nomad.service" "studio-router.service" "studio-host.service" "studio-vm-screen.service" ];
220 after = [ "nomad.service" "studio-host.service" "studio-vm-screen.service" ];
186221 unitConfig.ConditionPathExists = [ "/var/lib/studio/dashboard.token" "/var/lib/studio/ca-bundle.crt" "/opt/studio/current" ];
187222 path = [ pkgs.podman pkgs.coreutils pkgs.curl pkgs.openssl pkgs.systemd ];
188223 serviceConfig = {
......@@ -210,6 +245,7 @@ in
210245 --env=HOME=/data --env=XDG_CACHE_HOME=/data/cache \
211246 --volume=/var/lib/studio/dashboard:/data:rw \
212247 --volume=/run/studio-host:/run/studio-host:ro \
248 --volume=/run/studio-vm-screen:/run/studio-vm-screen:ro \
213249 --volume=${proxyToken}:/run/secrets/dashboard-proxy.token:ro \
214250 --volume=/var/lib/studio/dashboard.token:/run/secrets/dashboard-nomad.token:ro \
215251 --volume=/var/lib/studio/ca-bundle.crt:/run/secrets/ca-bundle.crt:ro \
readme.md-189
......@@ -18,44 +18,6 @@ it -- It's kind of like "easy kubernetes."
1818
1919[paper clover]: https://paperclover.net
2020
21## deployment loop
22
23```sh
24python3 tools/deploy.py stage shale # preview the working files, even without a commit message
25python3 tools/deploy.py stage shale # update the same URL and staging data after another edit
26python3 tools/deploy.py publish # upload the described, conflict-free main commit for GUI review
27python3 tools/deploy.py prod # upload and deploy main directly
28```
29
30Production always deploys a snapshot exported from `main`, never working files
31or a stage. The dashboard's **deploy main** page shows the uploaded commit and
32its message; deploying applies the full repository configuration and retains
33production data. A newer upload invalidates an older deployment confirmation.
34Sibling application build sources declared in `build-source.json` are bundled
35at upload time. Their frozen contents are part of the release digest.
36
37`main` joins the infra-2 and home-infra histories. Its tree contains infra-2;
38the retired configuration remains available in the home-infra parent history.
39
40## installer
41
42After publishing main, build the prepared USB image on an x86 Linux host:
43
44```sh
45nix build --extra-experimental-features 'nix-command flakes' path:/opt/studio/main#installer
46```
47
48The ISO is in `result/iso/`. It boots a live installer with this Mac's SSH key,
49ZFS and migration tools, the uploaded repository at `/etc/infra-2`, and a cached
50dashboard image. It does not install automatically. The physical installation
51uses `#zenith` after generating its hardware configuration; the existing data
52pool and service state follow the [handoff](tools/legacy-handoff.md).
53
54On Zenith, the live installer uses the same wired addresses and gateway as the
55installed system. Once firmware boots the USB, connect from this Mac with
56`ssh root@10.0.0.1`. SSH starts automatically and accepts the admin key only;
57no monitor, local login, or DHCP address lookup is needed after USB boot.
58
5921## filesystem layout
6022
6123The computer mounts the ZFS root dataset under `/srv`, meaning "server," loosely
......@@ -94,154 +56,3 @@ Media is it's own dataset so it can be snapshotted independently of my personal
9456data (less frequent, lower retention), and all my personal files are on the same
9557dataset to allow fast move/copying between top level folders. Services use their
9658own datasets to implement copy-on-write forks.
97
98## testing domains on a Mac
99
100[tools/mac-domains.py](tools/mac-domains.py) routes `.studio.test` and its nested
101subdomains through the rehearsal VM's HTTPS, DNS, and login services. Start the
102rehearsal SSH forwards first, then run:
103
104```sh
105sudo /usr/bin/python3 tools/mac-domains.py start /path/to/rehearsal-ca.crt
106```
107
108The relay binds loopback ports, drops administrator privileges, and passes TLS
109through to the VM. Local UDP DNS queries use the SSH tunnel's TCP DNS connection.
110Existing certificate trust is preserved. Mac DNS and hosts
111settings are backed up before editing; `sudo /usr/bin/python3 tools/mac-domains.py
112stop` restores them and stops the relay. Undo refuses to overwrite later edits.
113After a Mac restart, run stop and start again to restart the relay.
114
115The dashboard package uses `home-dashboard`. Existing state paths, environment
116names, service IDs, and telemetry names keep their old names for compatibility.
117
118## dashboard boundary
119
120NixOS runs `studio-dashboard` in a non-root Podman container with a read-only
121root, private network, resource limits, and explicit data mounts. The small
122`studio-host` service authenticates the dashboard UID on its Unix socket and
123performs bounded ZFS, VM, deployment, host-sampling, and identity operations.
124Host control sockets and management credentials stay outside the container.
125
126Snowglobe and Copyparty use the Rust dashboard's accounts and host-only sessions.
127Account state lives in `/var/lib/studio/dashboard/accounts.sqlite`. Deployment
128backups include consistent SQLite copies and profile pictures; `data-restore`
129accepts `dashboard` and preserves a safety copy before restoring. Invitations reserve a username and groups,
130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.
131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related
132origin metadata for Snowglobe. Keycloak remains available for other services.
133The sign-in page shares the original Keycloak theme stylesheet and artwork.
134Passkeys support username-free sign-in and browser autofill; the remember-me
135checkbox chooses between a browser session cookie and a 30-day cookie.
136
137`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`
138exports account IDs, groups, password hashes and public passkey credentials without
139changing the source realm. Keep the export private. The dashboard imports
140`accounts-import.json` from its data directory at startup and removes it after
141success; importing the same export again is safe, while a different export is
142refused once accounts exist. `home-dashboard --import-accounts /private/path/accounts.json`
143supports an offline rehearsal with a separate `STUDIO_DATA_DIR`.
144
145The MCP tab manages separate observability, agent, and Shale catalogs through
146the native dashboard account. Each connection has explicit service, machine, or
147repository grants; Shale credentials belong to the signed-in user. Agent Relay's
148existing outbound client protocol connects to the Rust server.
149
150Build the image with `nix build .#dashboard-image` on Linux. Run
151`python3 tools/dashboard-unit-test.py --output /tmp/dashboard-checks.json` on the
152rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP
153connectors with disposable fixtures. `--relay-agent-dir` includes the existing
154Agent Relay client interoperability check; `--browser-ready-file` temporarily
155routes the public dashboard to the fixture for browser and SSO load checks.
156
157## local agents
158
159On each Mac or Linux machine, run this from your usual terminal:
160
161```sh
162curl -fsSL https://snowglobe.paperclover.net/agent/install.sh | sh
163```
164
165On Windows, use a PowerShell window without administrator privileges:
166
167```powershell
168irm https://snowglobe.paperclover.net/agent/install.ps1 | iex
169```
170
171The installer downloads a private Node runtime, verifies its SHA-256 checksum,
172and installs the agent without npm or a repository checkout. On NixOS, it
173installs the runtime through Nix into the agent folder. Prompts ask for a
174machine name, existing project folders where new chats may start, and optional
175experimental Codex desktop control on macOS or Linux. No folders or desktop
176control are enabled on a fresh install unless selected. Codex or Claude Code
177must already be installed and signed in as your login user.
178
179While the installer waits, open **MCP → Settings → Local agents**, enter the printed
180pairing code, and click **Link machine**. Finish installation in the terminal.
181The machine appears **Online** when its background agent connects. Pairing
182belongs to the signed-in dashboard account; each machine connects outward and
183needs no incoming firewall port. Startup uses a systemd user service on Linux,
184a LaunchAgent on macOS, and a current-user scheduled task at logon on Windows.
185Linux needs an active systemd user session. The agent starts immediately after
186installation and again at login.
187
188Copy **Local agents**' endpoint, `https://snowglobe.paperclover.net/mcp/agents`,
189into the AI client's MCP connector settings using OAuth. Sign in to the
190dashboard and select the machines the client may access. The consent screen
191shows whether the connection requests session control. A granted machine
192exposes saved Codex and Claude Code chats; project folders constrain **new**
193chats, not saved-chat reads or the permissions of existing chats.
194
195Example requests to the connected AI client:
196
197```text
198List my machines, target "Work PC", and show its recent Codex chats.
199Read the latest chat in that list.
200Start a Codex chat on "Work PC" in C:\Users\Clover\dev\site:
201check the build and fix the failing tests.
202Read that chat again to check the result.
203```
204
205Read access supports listing machines and chats and reading transcripts.
206Session control adds starting chats, sending messages, and interrupting turns.
207Writes always select one machine. Agent-owned Codex and Claude Code chats
208support these operations; existing Codex desktop control is experimental and
209requires the installer option. Existing Claude Code chats accept messages only
210when their local inbox supports delivery. Windows installs support saved-chat
211reads and agent-owned CLI chats; this installer does not enable existing
212desktop chat control there. A submitted message acknowledges delivery; read
213the chat again for its result. Commands needing local approval are refused.
214
215For an external script, create an **API key** in the MCP tab, select its
216machines, and enable **Allow session control** only if required. Use the key
217as a bearer token with `/api/v1/machines` and
218`/api/v1/machines/{id}/commands`. Keep it in the script's secret store. Unlinking
219a machine disconnects it and revokes its machine credential; revoking a client
220connection removes only that client's access.
221
222Rerun the install command to update the agent or change project folders. It
223keeps the machine identity and pairing. Leaving the first folder answer blank
224keeps existing folders; entering `-` clears them. A reinstall needs the existing
225pairing to remain active. Unlink first, then remove the saved `agent.json` if
226you want a new pairing or a different dashboard account.
227
228The installed `agent-relay` command accepts `status`, `start`, `stop`, and
229`uninstall`. Use its full path:
230
231| Platform | Command | Logs |
232| --- | --- | --- |
233| Linux | `~/.local/share/agent-relay/agent-relay status` | `journalctl --user -u agent-relay -f` |
234| macOS | `"$HOME/Library/Application Support/AgentRelay/agent-relay" status` | `~/Library/Application Support/AgentRelay/agent.log` |
235| Windows | `& "$env:LOCALAPPDATA\AgentRelay\agent-relay.cmd" status` | `%LOCALAPPDATA%\AgentRelay\agent.log` |
236
237Linux honors `XDG_DATA_HOME` and `XDG_CONFIG_HOME`. Uninstall removes startup
238registration and stops the agent, preserving pairing and session files. Pairing
239is in `~/.config/agent-relay/agent.json` on macOS/Linux, or
240`%LOCALAPPDATA%\AgentRelay\config\agent.json` on Windows.
241
242The local client source remains in the sibling Agent Relay project identified
243by `dashboard/agent/source.json`. `tools/deploy.py` bundles it into each frozen
244release before computing its digest. For a direct dashboard build or local
245preview, run `pnpm --dir dashboard install --frozen-lockfile` and
246`python3 tools/build-agent.py` first. The generated `relay.mjs` is a deployment
247artifact and is not checked into this repository.
service/shale/readme/readme.js+67-7
......@@ -2,31 +2,91 @@
22 const readme = document.querySelector("#readme .markdown");
33 if (!readme || !window.markdownit || !window.DOMPurify) return;
44
5 const response = await fetch("/snowbound/plain/main/readme.md");
5 const repo = document.querySelector('meta[name="astheno.shale.repo.name"]')?.content;
6 if (!repo) return;
7 const tree = await fetch(`/${encodeURIComponent(repo)}/tree/-/`);
8 if (!tree.ok) return;
9 const page = new DOMParser().parseFromString(await tree.text(), "text/html");
10 const file = [...page.querySelectorAll(".objblob a[href]")]
11 .find(link => /^readme\.md$/i.test(link.textContent.trim()));
12 if (!file) return;
13 const source = new URL(file.getAttribute("href"), tree.url);
14 const linkBase = new URL("./", source);
15 source.pathname = source.pathname.replace(`/${encodeURIComponent(repo)}/tree/`, `/${encodeURIComponent(repo)}/plain/`);
16 const response = await fetch(source);
617 if (!response.ok) return;
718
819 const markdown = await response.text();
920 const rendered = window.markdownit({ html: true }).render(markdown);
1021 const fragment = window.DOMPurify.sanitize(rendered, {
11 USE_PROFILES: { html: true },
22 // https://github.com/gjtorikian/html-pipeline/blob/main/lib/html_pipeline/sanitization_filter.rb
23 ALLOWED_TAGS: [
24 "h1", "h2", "h3", "h4", "h5", "h6", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
25 "div", "ins", "del", "sup", "sub", "p", "picture", "ol", "ul", "table", "thead", "tbody", "tfoot",
26 "blockquote", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td",
27 "th", "s", "strike", "summary", "details", "caption", "figure", "figcaption", "abbr", "bdo", "cite",
28 "dfn", "mark", "small", "source", "span", "time", "wbr",
29 ],
30 ALLOWED_ATTR: [
31 "href", "src", "longdesc", "loading", "alt", "itemscope", "itemtype", "cite", "srcset",
32 "abbr", "accept", "accept-charset", "accesskey", "action", "align", "aria-describedby", "aria-hidden",
33 "aria-label", "aria-labelledby", "axis", "border", "char", "charoff", "charset", "checked", "clear",
34 "cols", "colspan", "compact", "coords", "datetime", "dir", "disabled", "enctype", "for", "frame",
35 "headers", "height", "hreflang", "hspace", "ismap", "label", "lang", "maxlength", "media", "method",
36 "multiple", "nohref", "noshade", "nowrap", "open", "progress", "prompt", "readonly", "rel", "rev",
37 "role", "rows", "rowspan", "rules", "scope", "selected", "shape", "size", "span", "start", "summary",
38 "tabindex", "title", "type", "usemap", "valign", "value", "width", "itemprop", "style",
39 ],
40 ALLOW_DATA_ATTR: false,
41 ALLOW_ARIA_ATTR: false,
1242 RETURN_DOM_FRAGMENT: true,
1343 });
44 for (const element of fragment.querySelectorAll("[style]")) {
45 const ratio = element.tagName === "IMG" ? element.style.aspectRatio : "";
46 element.removeAttribute("style");
47 if (/^(?:auto\s+)?\d+(?:\.\d+)?(?:\s*\/\s*\d+(?:\.\d+)?)?$/.test(ratio)) {
48 element.style.aspectRatio = ratio;
49 }
50 }
51 for (const element of fragment.querySelectorAll("[align]")) {
52 const align = element.getAttribute("align").toLowerCase();
53 if (element.tagName === "IMG") {
54 if (["top", "middle", "bottom"].includes(align)) element.style.verticalAlign = align;
55 if (["left", "right"].includes(align)) element.style.float = align;
56 } else if (["left", "center", "right", "justify"].includes(align)) {
57 element.style.textAlign = align;
58 }
59 }
1460
15 const mediaBase = new URL("/snowbound/plain/main/", location.origin);
16 const linkBase = new URL("/snowbound/tree/main/", location.origin);
61 const mediaBase = new URL("./", source);
1762 const rebase = (value, base) =>
1863 value && !value.startsWith("/") && !value.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(value)
1964 ? new URL(value, base).href
2065 : value;
2166
22 for (const image of fragment.querySelectorAll("img[src]")) {
67 await Promise.all([...fragment.querySelectorAll("img[src]")].map(async image => {
2368 image.src = rebase(image.getAttribute("src"), mediaBase);
2469 for (const dimension of ["width", "height"]) {
2570 const value = image.getAttribute(dimension);
2671 if (value && /^\d+$/.test(value)) image.style[dimension] = `${value}px`;
2772 }
28 }
29 for (const pictureSource of fragment.querySelectorAll("source[srcset]")) {
73 if (image.style.width) {
74 if (!image.style.aspectRatio && image.style.height) {
75 image.style.aspectRatio = `auto ${image.getAttribute("width")} / ${image.getAttribute("height")}`;
76 }
77 image.style.height = "auto";
78 }
79 // Shale serves raw SVG as text/plain, which browsers reject as an image.
80 const url = new URL(image.src);
81 if (url.origin === location.origin && url.pathname.startsWith(mediaBase.pathname) && /\.svg$/i.test(url.pathname)) {
82 const source = await fetch(url).then(response => response.ok ? response.text() : null).catch(() => null);
83 if (source) {
84 const svg = window.DOMPurify.sanitize(source, { USE_PROFILES: { svg: true, svgFilters: true } });
85 image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
86 }
87 }
88 }));
89 for (const pictureSource of fragment.querySelectorAll("img[srcset], source[srcset]")) {
3090 pictureSource.srcset = pictureSource.srcset.split(",").map(candidate => {
3191 const [, path, descriptor] = candidate.trim().match(/^(\S+)(.*)$/) || [];
3292 return path ? rebase(path, mediaBase) + descriptor : candidate;
service/shale/service.pkl+1-1
......@@ -30,7 +30,7 @@ container {
3030 ["/-/studio-readme/"] = "readme"
3131 }
3232 headHtml {
33 ["/snowbound/"] = """
33 ["/*"] = """
3434 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script>
3535 """
3636 }
tools/dashboard-host.py+11-4
......@@ -33,6 +33,7 @@ TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"}
3333MAX_REQUEST = 65536
3434MAX_RESPONSE = 16 * 1024 * 1024
3535STREAM_SLOTS = threading.BoundedSemaphore(4)
36GUEST_SLOTS = threading.BoundedSemaphore(4)
3637VM_PREPARATION_SLOT = threading.BoundedSemaphore(1)
3738INDEX_SNAPSHOT = r"index-[0-9]+(?:-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})?"
3839
......@@ -322,6 +323,11 @@ def stream_console(connection, host, request):
322323 except BlockingIOError:
323324 raise Rejected("This console is already open. Close its other console tab, then reconnect.") from None
324325 tty.setraw(descriptor)
326 elif request["operation"] == "vm.guest":
327 screen = resources.enter_context(socket.socket(socket.AF_UNIX, socket.SOCK_STREAM))
328 screen.settimeout(10)
329 screen.connect(details["path"])
330 screen.setblocking(False)
325331 else:
326332 identity = details["uuid"]
327333 deadline = time.monotonic() + 5
......@@ -427,17 +433,18 @@ def serve_connection(connection, host, allowed_uid):
427433 if len(line) > MAX_REQUEST or not line.endswith(b"\n"):
428434 raise Rejected("The host request is too large or incomplete.")
429435 request = json.loads(line)
430 if isinstance(request, dict) and request.get("operation") in {"vm.console", "vm.serial", "vm.upload"}:
431 if not STREAM_SLOTS.acquire(blocking=False):
436 if isinstance(request, dict) and request.get("operation") in {"vm.console", "vm.serial", "vm.guest", "vm.upload"}:
437 streams = GUEST_SLOTS if request["operation"] == "vm.guest" else STREAM_SLOTS
438 if not streams.acquire(blocking=False):
432439 raise Rejected("Four VM connections or uploads are open. Close one and try again.")
433440 try:
434 if request["operation"] in {"vm.console", "vm.serial"}:
441 if request["operation"] in {"vm.console", "vm.serial", "vm.guest"}:
435442 stream_console(connection, host, request)
436443 else:
437444 stream_upload(connection, request)
438445 return
439446 finally:
440 STREAM_SLOTS.release()
447 streams.release()
441448 response = {"value": host.handle(request)}
442449 except Rejected as error:
443450 response = {"error": str(error), "status": 400}
tools/dashboard-vms-test.py+7-1
......@@ -82,6 +82,11 @@ class VMTests(unittest.TestCase):
8282 with self.subTest(name=name):
8383 spec = windows if name.startswith('Windows') else linux
8484 self.assertEqual(vms.installer_profile(Path(name), spec), expected)
85 self.assertEqual(vms.profile('linux-aarch64-virtio')['video'], 'virtio')
86 self.assertEqual(vms.profile('linux-aarch64-virtio')['keyboard'], 'usb')
87 self.assertEqual(vms.profile('linux-aarch64-virtio')['serial'], 'ttyAMA0')
88 self.assertEqual(vms.profile('windows-q35-secure')['cpu_mode'], 'host-passthrough')
89 self.assertEqual(vms.profile('windows-q35-secure')['clock'], 'utc')
8590 with self.assertRaisesRegex(ValueError, 'Windows ARM'):
8691 vms.installer_profile(Path('Windows 11 26H2 English arm64.iso'), windows)
8792
......@@ -129,7 +134,7 @@ class VMTests(unittest.TestCase):
129134 patch.object(vms.subprocess, 'run', return_value=SimpleNamespace(stdout='$6$hash\n')), \
130135 patch.object(vms.secrets, 'token_urlsafe', return_value='generated-password'), \
131136 patch.object(vms.secrets, 'token_hex', side_effect=['1234abcd', 'instance']):
132 vms.linux_seed(directory, 'snow', 'NixOS')
137 vms.linux_seed(directory, 'snow', 'NixOS', 'ttyAMA0')
133138 config = captured['config']
134139 self.assertEqual(config['bootcmd'][0], ['rm', '-f', '/etc/gdm/custom.conf'])
135140 self.assertIn('/run/current-system/sw/bin/getent', config['bootcmd'][1][-1])
......@@ -140,6 +145,7 @@ class VMTests(unittest.TestCase):
140145 'content': 'snowglobe-vm-1234abcd\n',
141146 }])
142147 self.assertEqual(config['runcmd'][0][-1], 'sshd')
148 self.assertEqual(config['runcmd'][1][-1], 'serial-getty@ttyAMA0.service')
143149
144150 def test_console_cannot_choose_an_arbitrary_network_target(self):
145151 xml = "<domain><uuid>fixture-vm</uuid><devices><graphics type='vnc' listen='127.0.0.1' port='5901'/></devices></domain>"
tools/guest-channel-test.py created+30
......@@ -0,0 +1,30 @@
1#!/usr/bin/env python3
2import sys
3import unittest
4from unittest.mock import patch
5import vms
6
7
8class ChannelTests(unittest.TestCase):
9 def request(self, source, state="running", channel=None):
10 channel = vms.GUEST_CHANNEL if channel is None else channel
11 xml = f'<domain id="9"><name>fixture</name><devices><channel type="unix"><source mode="bind" path="{source}"/><target type="virtio" name="{channel}"/></channel></devices></domain>'
12 with patch.object(sys, "argv", ["vms.py", "guest", '{"name":"fixture"}']), patch.object(vms, "virsh", side_effect=[xml, state]):
13 return vms.main()
14
15 def test_only_the_running_domains_owned_channel_is_opened(self):
16 path = f"/run/libvirt/qemu/channel/9-fixture/{vms.GUEST_CHANNEL}"
17 self.assertEqual(self.request(path), {"path": path})
18 for source, state, channel in [("/run/studio-host/host.sock", "running", None),
19 (path.replace("9-fixture", "8-other"), "running", None),
20 (path, "paused", None), (path, "running", "org.qemu.guest_agent.0")]:
21 with self.subTest(source=source, state=state, channel=channel), self.assertRaises(ValueError):
22 self.request(source, state, channel)
23 with self.assertRaises(ValueError):
24 vms.validate("guest", {"name": "fixture", "path": "/run/studio-host/host.sock"})
25 with self.assertRaises(ValueError):
26 vms.validate("guest", {"name": "../other"})
27
28
29if __name__ == "__main__":
30 unittest.main()
tools/release.py+3-2
......@@ -15,7 +15,7 @@ ROOT = Path("/opt/studio")
1515RELEASES = ROOT / "releases"
1616STATE = Path("/var/lib/studio")
1717HISTORY = STATE / "deployments.json"
18SOURCES = ("config", "service", "tools", "nixos", "dashboard", "flake.nix", "flake.lock", "readme.md")
18SOURCES = ("config", "service", "tools", "nixos", "dashboard", "guest", "flake.nix", "flake.lock", "readme.md")
1919RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z")
2020STAGE_ID = re.compile(r"[a-z][a-z0-9-]*\Z")
2121
......@@ -42,6 +42,7 @@ def files(root):
4242 if relative.parts[:2] in (
4343 ("dashboard", "node_modules"), ("dashboard", "dist"),
4444 ("dashboard", ".cache"), ("dashboard", "data"), ("dashboard", "target"),
45 ("guest", "build"),
4546 ):
4647 continue
4748 if any(part in {".DS_Store", "__pycache__", ".identities.lock", "identities.pending"} or part.startswith("._") or part.endswith(".pyc") for part in relative.parts):
......@@ -73,7 +74,7 @@ def host_digest(root):
7374 digest = hashlib.sha256()
7475 paths = sorted(path for path, relative in files(root)
7576 if relative.parts[0] in {"nixos", "dashboard", "config", "service"}
76 or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py"})
77 or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py", "tools/vm-screen.py", "guest/protocol.json"})
7778 for path in paths:
7879 digest.update(str(path.relative_to(root)).encode())
7980 _hash_contents(digest, path)
tools/vm-screen-test.py created+248
......@@ -0,0 +1,248 @@
1#!/usr/bin/env python3
2import asyncio
3import base64
4import importlib.util
5import json
6from pathlib import Path
7import random
8import struct
9import threading
10import unittest
11import zlib
12from types import SimpleNamespace
13from unittest.mock import Mock
14
15
16spec = importlib.util.spec_from_file_location("vm_screen", Path(__file__).with_name("vm-screen.py"))
17screen = importlib.util.module_from_spec(spec)
18spec.loader.exec_module(screen)
19
20
21class Writer:
22 def __init__(self):
23 self.data = bytearray()
24 self.closed = False
25 self.transport = self
26
27 def write(self, data):
28 self.data.extend(data)
29
30 async def drain(self):
31 pass
32
33 def get_write_buffer_size(self):
34 return len(self.data)
35
36 def is_closing(self):
37 return self.closed
38
39 def close(self):
40 self.closed = True
41
42
43class DesktopTests(unittest.IsolatedAsyncioTestCase):
44 def setUp(self):
45 self.reader, self.writer = asyncio.StreamReader(), Writer()
46 self.messages = []
47 self.desktop = screen.Desktop(self.reader, self.writer, self.messages.append)
48 self.desktop.resize(4, 2)
49
50 async def update(self, rectangles):
51 self.reader.feed_data(struct.pack("!BBH", 0, 0, len(rectangles)) + b"".join(rectangles))
52 self.reader.feed_eof()
53 self.presented = self.resized = 0
54
55 def present():
56 self.presented += 1
57
58 def resize():
59 self.resized += 1
60
61 with self.assertRaises(asyncio.IncompleteReadError):
62 await self.desktop.read(present, resize)
63
64 async def test_handshake(self):
65 self.reader.feed_data(b"RFB 003.008\n\x01\x01\0\0\0\0" + struct.pack("!HH16sI", 4, 2, bytes(16), 4) + b"test")
66 await self.desktop.start()
67 self.assertEqual(self.writer.data[:14], b"RFB 003.008\n\x01\x01")
68 self.assertEqual(self.writer.data[-10:], struct.pack("!BBHHHH", 3, 0, 0, 0, 4, 2))
69
70 async def test_raw_and_overlapping_copy_present_once(self):
71 pixels = b"".join(bytes([value, 0, 0, 0]) for value in range(1, 9))
72 await self.update([
73 struct.pack("!HHHHi", 0, 0, 4, 2, 0) + pixels,
74 struct.pack("!HHHHiHH", 1, 0, 3, 2, 1, 0, 0),
75 ])
76 self.assertEqual(list(self.desktop.pixels[::4]), [1, 1, 2, 3, 5, 5, 6, 7])
77 self.assertEqual(self.presented, 1)
78
79 async def test_idle_and_cursor_do_not_encode(self):
80 await self.update([struct.pack("!HHHHi", 0, 0, 2, 1, -239) + bytes([1, 2, 3, 0, 4, 5, 6, 0, 128])])
81 self.assertEqual(self.presented, 0)
82 encoded = base64.b64decode(self.messages[0]["image"].split(",")[1])
83 length = struct.unpack("!I", encoded[33:37])[0]
84 self.assertEqual(zlib.decompress(encoded[41:41 + length]), bytes([0, 3, 2, 1, 255, 6, 5, 4, 0]))
85
86 async def test_resize_and_out_of_bounds(self):
87 await self.update([struct.pack("!HHHHi", 0, 0, 2, 2, -223)])
88 self.assertEqual((self.desktop.width, self.desktop.height, self.resized), (2, 2, 1))
89 self.reader = asyncio.StreamReader()
90 self.desktop.reader = self.reader
91 with self.assertRaisesRegex(ValueError, "outside"):
92 await self.update([struct.pack("!HHHHi", 1, 0, 2, 1, 0)])
93 for width, height in [(0, 2), (4097, 1), (65535, 65535)]:
94 with self.assertRaises(ValueError):
95 self.desktop.resize(width, height)
96
97 def test_input_validation_and_release(self):
98 self.desktop.input({"type": "key", "key": 65507, "down": True})
99 self.desktop.input({"type": "pointer", "x": -1, "y": 10, "buttons": 1})
100 self.desktop.input({"type": "release"})
101 self.assertFalse(self.desktop.keys)
102 self.assertEqual(self.writer.data[-6:], struct.pack("!BBHH", 5, 0, 0, 1))
103 for message in [{"type": "key", "key": True, "down": True}, {"type": "pointer", "x": 1, "y": 1, "buttons": 256}]:
104 with self.assertRaises(ValueError):
105 self.desktop.input(message)
106
107
108class GuestTests(unittest.IsolatedAsyncioTestCase):
109 def setUp(self):
110 self.guest = screen.GuestDesktop("fixture")
111 self.messages = []
112 self.presented = self.resized = 0
113 self.viewer = Mock(desktop=SimpleNamespace(cursor={"type": "cursor", "source": "qemu"}),
114 writer=Writer(), send=self.messages.append, resize=self.resize, present=self.present)
115 self.guest.screens = {self.viewer}
116
117 def present(self):
118 self.presented += 1
119
120 def resize(self):
121 self.resized += 1
122
123 async def packets(self, *packets):
124 reader = asyncio.StreamReader()
125 for name, words, data in packets:
126 kind = screen.GUEST_PROTOCOL["packets"][name]["id"]
127 payload = struct.pack("!" + "I" * len(words), *words) + data
128 reader.feed_data(struct.pack("!4sII", b"SGV1", kind, len(payload)) + payload)
129 reader.feed_eof()
130 with self.assertRaises(asyncio.IncompleteReadError):
131 await self.guest.read(reader)
132
133 async def test_dirty_rectangles_and_cursor_do_not_reencode_video(self):
134 await self.packets(("screen", [4, 2, 0, 0, 4, 2], bytes(32)),
135 ("screen", [4, 2, 2, 1, 1, 1], b"\x01\x02\x03\0"),
136 ("cursor", [7, 1, 1, 0, 0, 2], struct.pack("!I", 17) + bytes([0,0,0,0,7]) + struct.pack("!I", 33) + bytes([1,2,3,255,0])),
137 ("pointer", [7, 0xffffffff, 9, 1], b""))
138 self.assertEqual(self.guest.pixels[24:28], b"\x01\x02\x03\0")
139 self.assertEqual((self.presented, self.resized), (2, 1))
140 self.assertEqual([message["duration"] for message in self.messages if message["type"] == "cursor"], [17, 33])
141 self.assertIn("invert", self.messages[0])
142 self.assertEqual(self.guest.pointer["x"], -1)
143
144 async def test_suspend_restores_fallback_and_requires_full_refresh(self):
145 await self.packets(("screen", [2, 2, 0, 0, 2, 2], bytes(16)), ("suspend", [], b""))
146 self.assertFalse(self.guest.pixels)
147 self.assertEqual(self.messages[-1]["source"], "qemu")
148 with self.assertRaisesRegex(ValueError, "complete"):
149 await self.packets(("screen", [2, 2, 0, 0, 1, 1], bytes(4)))
150 self.viewer.desktop.cursor = None
151 await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4)), ("suspend", [], b""))
152 self.assertTrue(self.messages[-1]["fallback"])
153
154 async def test_untrusted_guest_packets_are_bounded(self):
155 for packet in [("screen", [4097, 1, 0, 0, 1, 1], bytes(4)),
156 ("screen", [2, 2, 1, 1, 2, 2], bytes(16)),
157 ("cursor", [1, 1, 1, 1, 0, 1], struct.pack("!I", 100) + bytes(5)),
158 ("cursor", [1, 1, 1, 0, 0, 1], struct.pack("!I", 0) + bytes(5)),
159 ("pointer", [1, 0, 0, 2], b""), ("suspend", [], b"x")]:
160 with self.subTest(packet=packet), self.assertRaises(ValueError):
161 await self.packets(packet)
162 reader = asyncio.StreamReader()
163 reader.feed_data(struct.pack("!4sII", b"SGV1", 1, 0xffffffff))
164 with self.assertRaises(ValueError):
165 await self.guest.read(reader)
166
167 async def test_viewer_close_during_cursor_fanout(self):
168 await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4)))
169 self.viewer.send = lambda _: self.guest.screens.discard(self.viewer)
170 await self.packets(("cursor", [1, 1, 1, 0, 0, 2], (struct.pack("!I", 100) + bytes(5)) * 2))
171 self.assertFalse(self.guest.screens)
172 self.assertEqual(len(self.guest.cursor), 2)
173
174 async def test_missing_heartbeat_times_out_but_does_not_encode(self):
175 await self.packets(("screen", [1, 1, 0, 0, 1, 1], bytes(4)), ("heartbeat", [], b""))
176 self.assertEqual(self.presented, 1)
177 reader = asyncio.StreamReader()
178 with self.assertRaises(TimeoutError):
179 await self.guest.read(reader)
180
181
182class StreamTests(unittest.IsolatedAsyncioTestCase):
183 async def asyncSetUp(self):
184 self.writer = Writer()
185 desktop = screen.Desktop(asyncio.StreamReader(), Writer(), lambda _: None)
186 desktop.resize(320, 200)
187 self.stream = screen.Screen(self.writer, desktop)
188
189 async def asyncTearDown(self):
190 self.writer.close()
191 self.stream.close()
192
193 async def test_browser_payload_and_sending_direction(self):
194 sdp = "\r\n".join([
195 "v=0", "o=- 1 1 IN IP4 127.0.0.1", "s=-", "t=0 0", "a=group:BUNDLE 0",
196 "m=video 9 UDP/TLS/RTP/SAVPF 96 103", "c=IN IP4 0.0.0.0", "a=mid:0", "a=recvonly",
197 "a=rtcp-mux", "a=ice-ufrag:abcd", "a=ice-pwd:abcdefghijklmnopqrstuvwxyz",
198 "a=setup:actpass", "a=fingerprint:sha-256 " + ":".join(["00"] * 32),
199 "a=rtpmap:96 VP8/90000", "a=rtpmap:103 H264/90000",
200 "a=fmtp:103 packetization-mode=1;profile-level-id=42e01f;level-asymmetry-allowed=1", "",
201 ])
202 self.stream.signal({"type": "offer", "sdp": sdp})
203 async with asyncio.timeout(5):
204 while b'"type":"answer"' not in self.writer.data:
205 await asyncio.sleep(0.01)
206 answer = next(json.loads(line) for line in self.writer.data.splitlines() if json.loads(line)["type"] == "answer")
207 self.assertIn("a=sendonly", answer["sdp"])
208 self.assertIn("a=rtpmap:103 H264/90000", answer["sdp"])
209 self.assertEqual(self.stream.pipeline.get_by_name("pay").get_property("pt"), 103)
210
211 async def test_stalled_guest_closes_and_ignores_queued_input(self):
212 message = json.dumps({"type": "clipboard", "text": "a" * 65536})
213 for _ in range(64):
214 self.stream.input(message)
215 self.assertTrue(self.writer.closed)
216 self.assertLess(len(self.stream.desktop.writer.data), 327680)
217
218 async def test_rtp_packets_fit_tunneled_network(self):
219 Gst = screen.Gst
220 pipeline = self.stream.pipeline
221 pipeline.set_state(Gst.State.NULL)
222 pipeline.remove(self.stream.rtc)
223 sink = Gst.ElementFactory.make("appsink")
224 sink.set_property("sync", False)
225 pipeline.add(sink)
226 pipeline.get_by_name("codec").link(sink)
227 pipeline.set_state(Gst.State.PLAYING)
228 pixels = random.Random(0).randbytes(len(self.stream.desktop.pixels))
229 self.stream.frames.emit("push-buffer", Gst.Buffer.new_wrapped(pixels))
230 packets = []
231 async with asyncio.timeout(5):
232 while True:
233 sample = sink.emit("try-pull-sample", 0)
234 if sample:
235 buffer = sample.get_buffer()
236 packets.append(buffer.get_size())
237 if buffer.extract_dup(1, 1)[0] & 0x80:
238 break
239 else:
240 await asyncio.sleep(0.01)
241 self.assertGreater(len(packets), 1)
242 self.assertLessEqual(max(packets), 1200)
243
244
245if __name__ == "__main__":
246 screen.Gst.init(None)
247 threading.Thread(target=screen.GLib.MainLoop().run, daemon=True).start()
248 unittest.main()
tools/vm-screen.py created+600
......@@ -0,0 +1,600 @@
1#!/usr/bin/env python3
2import asyncio
3import base64
4import contextlib
5import json
6import logging
7import os
8from pathlib import Path
9import socket
10import struct
11import threading
12import zlib
13
14import gi
15
16gi.require_version("Gst", "1.0")
17gi.require_version("GstSdp", "1.0")
18gi.require_version("GstWebRTC", "1.0")
19from gi.repository import GLib, Gst, GstSdp, GstWebRTC
20
21
22MAX_PIXELS = 3840 * 2160
23STUN = os.environ.get("STUDIO_VM_STUN_SERVER", "stun://stun.cloudflare.com:3478")
24SLOTS = asyncio.Semaphore(4)
25GUEST_PROTOCOL = json.loads(Path(os.environ.get("STUDIO_VM_GUEST_PROTOCOL", Path(__file__).resolve().parent.parent / "guest/protocol.json")).read_text())
26GUESTS = {}
27
28
29def png(width, height, pixels):
30 def chunk(kind, data):
31 return struct.pack("!I", len(data)) + kind + data + struct.pack("!I", zlib.crc32(kind + data))
32 rows = b"".join(b"\0" + pixels[row * width * 4:(row + 1) * width * 4] for row in range(height))
33 data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack("!IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")
34 return "data:image/png;base64," + base64.b64encode(data).decode()
35
36
37class GuestDesktop:
38 def __init__(self, name):
39 self.name = name
40 self.screens = set()
41 self.pixels = bytearray()
42 self.cursor = []
43 self.pointer = None
44
45 def suspend(self):
46 active = bool(self.pixels)
47 self.pixels = bytearray()
48 self.cursor.clear()
49 self.pointer = None
50 if active:
51 for screen in self.screens:
52 screen.resize()
53 screen.send(screen.desktop.cursor or {"type": "cursor", "source": "qemu", "frame": 0, "fallback": True, "x": 0, "y": 0})
54 screen.present()
55
56 async def read(self, reader):
57 limits = GUEST_PROTOCOL["limits"]
58 packets = {packet["id"]: (name, packet["fields"]) for name, packet in GUEST_PROTOCOL["packets"].items()}
59 while True:
60 async with asyncio.timeout(4):
61 magic, kind, length = struct.unpack("!4sII", await reader.readexactly(12))
62 if magic != GUEST_PROTOCOL["magic"].encode() or kind not in packets or length > limits["packet_bytes"]:
63 raise ValueError("Invalid guest display packet")
64 name, fields = packets[kind]
65 if length < len(fields) * 4:
66 raise ValueError("Incomplete guest display packet")
67 payload = await reader.readexactly(length)
68 values = dict(zip(fields, struct.unpack("!" + "I" * len(fields), payload[:len(fields) * 4])))
69 data = payload[len(fields) * 4:]
70 if name == "screen":
71 width, height = values["width"], values["height"]
72 x, y, w, h = values["x"], values["y"], values["rect_width"], values["rect_height"]
73 if not 0 < width <= limits["dimension"] or not 0 < height <= limits["dimension"] or width * height > limits["pixels"] or not w or not h or x + w > width or y + h > height or len(data) != w * h * 4:
74 raise ValueError("Invalid guest display rectangle")
75 resized = not self.pixels or (width, height) != (self.width, self.height)
76 if resized:
77 if (x, y, w, h) != (0, 0, width, height):
78 raise ValueError("Guest display needs a complete first frame")
79 self.width, self.height = width, height
80 self.pixels = bytearray(width * height * 4)
81 for row in range(h):
82 offset = ((y + row) * width + x) * 4
83 self.pixels[offset:offset + w * 4] = data[row * w * 4:(row + 1) * w * 4]
84 for screen in tuple(self.screens):
85 if resized:
86 screen.resize()
87 if self.pointer:
88 screen.send(self.pointer)
89 for cursor in self.cursor:
90 screen.send(cursor)
91 screen.present()
92 await asyncio.sleep(0)
93 elif name == "cursor":
94 width, height, count = values["width"], values["height"], values["frames"]
95 if not width and not height and not count and not data and not values["hot_x"] and not values["hot_y"]:
96 self.cursor = [{"type": "cursor", "source": "guest", "id": values["id"], "width": 0, "height": 0, "frame": 0, "frames": 0, "fallback": True, "x": 0, "y": 0}]
97 if self.pixels:
98 for screen in self.screens:
99 screen.send(self.cursor[0])
100 continue
101 pixels = width * height
102 frame_bytes = 4 + pixels * 5
103 if not 0 < width <= limits["cursor_size"] or not 0 < height <= limits["cursor_size"] or not 0 < count <= limits["cursor_frames"] or values["hot_x"] >= width or values["hot_y"] >= height or len(data) != frame_bytes * count:
104 raise ValueError("Invalid guest cursor")
105 self.cursor.clear()
106 for index in range(count):
107 frame = data[index * frame_bytes:(index + 1) * frame_bytes]
108 duration = struct.unpack("!I", frame[:4])[0]
109 if not 1 <= duration <= 60000:
110 raise ValueError("Invalid guest cursor duration")
111 mask = frame[4 + pixels * 4:]
112 if any(value & ~sum(GUEST_PROTOCOL["xor_bits"].values()) for value in mask):
113 raise ValueError("Invalid guest cursor inversion")
114 message = {"type": "cursor", "source": "guest", "id": values["id"], "width": width, "height": height,
115 "x": values["hot_x"], "y": values["hot_y"], "frame": index, "frames": count,
116 "duration": duration, "image": png(width, height, frame[4:4 + pixels * 4])}
117 if any(mask):
118 bits = GUEST_PROTOCOL["xor_bits"]
119 message["invert"] = png(width, height, b"".join(bytes([255 if value & bits["red"] else 0,
120 255 if value & bits["green"] else 0,
121 255 if value & bits["blue"] else 0,
122 255 if value else 0]) for value in mask))
123 self.cursor.append(message)
124 if self.pixels:
125 for screen in tuple(self.screens):
126 screen.send(message)
127 await asyncio.sleep(0)
128 elif name == "pointer":
129 if data or values["visible"] > 1:
130 raise ValueError("Invalid guest pointer")
131 self.pointer = {"type": "cursor-position", "id": values["id"], "visible": bool(values["visible"]),
132 "x": (values["x"] ^ 0x80000000) - 0x80000000, "y": (values["y"] ^ 0x80000000) - 0x80000000}
133 if self.pixels:
134 for screen in self.screens:
135 screen.send(self.pointer)
136 elif name == "suspend":
137 if data:
138 raise ValueError("Invalid guest display suspension")
139 self.suspend()
140 elif name == "heartbeat":
141 if data:
142 raise ValueError("Invalid guest display heartbeat")
143 else:
144 raise ValueError("Unexpected guest display request")
145
146 async def run(self):
147 while True:
148 writer = None
149 try:
150 reader, writer = await asyncio.wait_for(asyncio.open_unix_connection(
151 os.environ.get("STUDIO_HOST_SOCKET", "/run/studio-host/host.sock")), 10)
152 peer = writer.get_extra_info("socket")
153 if struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] != 0:
154 raise ValueError("Untrusted guest display broker")
155 writer.write(json.dumps({"operation": "vm.guest", "payload": {"name": self.name}}).encode() + b"\n")
156 size = struct.unpack("!I", await asyncio.wait_for(reader.readexactly(4), 70))[0]
157 if size > 65536:
158 raise ValueError("Invalid guest display broker response")
159 response = json.loads(await reader.readexactly(size))
160 if "error" not in response:
161 writer.write(struct.pack("!4sII", GUEST_PROTOCOL["magic"].encode(), GUEST_PROTOCOL["packets"]["refresh"]["id"], 0))
162 await self.read(reader)
163 except (OSError, ValueError, EOFError, asyncio.IncompleteReadError, TimeoutError) as error:
164 logging.info("Guest display %s: %s", self.name, error)
165 finally:
166 self.suspend()
167 if writer:
168 writer.close()
169 with contextlib.suppress(OSError, TimeoutError):
170 await asyncio.wait_for(writer.wait_closed(), 2)
171 await asyncio.sleep(2)
172
173
174class Desktop:
175 def __init__(self, reader, writer, notify):
176 self.reader, self.writer, self.notify = reader, writer, notify
177 self.keys = set()
178 self.pointer = (0, 0)
179 self.pixels = bytearray()
180 self.cursor = None
181
182 def resize(self, width, height):
183 if not 0 < width <= 4096 or not 0 < height <= 4096 or width * height > MAX_PIXELS:
184 raise ValueError("The VM screen exceeds the supported 4K display size.")
185 self.width, self.height = width, height
186 self.pixels = bytearray(width * height * 4)
187
188 async def start(self):
189 if not (await self.reader.readexactly(12)).startswith(b"RFB 003."):
190 raise ValueError("The VM screen did not respond. Restart the VM and reconnect.")
191 self.writer.write(b"RFB 003.008\n")
192 count = (await self.reader.readexactly(1))[0]
193 security = await self.reader.readexactly(count)
194 if 1 not in security:
195 raise ValueError("Remove the VM's VNC password in its display settings, then reconnect.")
196 self.writer.write(b"\x01")
197 if await self.reader.readexactly(4) != b"\0\0\0\0":
198 raise ValueError("The VM rejected the screen connection. Restart it and reconnect.")
199 self.writer.write(b"\x01")
200 width, height, _, length = struct.unpack("!HH16sI", await self.reader.readexactly(24))
201 if length > 65536:
202 raise ValueError("The VM screen sent an unsupported desktop name.")
203 await self.reader.readexactly(length)
204 self.resize(width, height)
205 self.writer.write(struct.pack("!B3xBBBBHHHBBB3x", 0, 32, 24, 0, 1, 255, 255, 255, 16, 8, 0))
206 encodings = (0, 1, -223, -224, -239)
207 self.writer.write(struct.pack("!BBH" + "i" * len(encodings), 2, 0, len(encodings), *encodings))
208 self.request(False)
209
210 def request(self, incremental=True):
211 self.writer.write(struct.pack("!BBHHHH", 3, incremental, 0, 0, self.width, self.height))
212
213 def input(self, message):
214 kind = message.get("type")
215 if kind == "key":
216 key, down = message.get("key"), message.get("down")
217 if type(key) is not int or not 0 <= key <= 0xFFFFFFFF or type(down) is not bool:
218 raise ValueError("Unsupported key event.")
219 if down:
220 self.keys.add(key)
221 else:
222 self.keys.discard(key)
223 self.writer.write(struct.pack("!BB2xI", 4, down, key))
224 elif kind == "pointer":
225 x, y, buttons = message.get("x"), message.get("y"), message.get("buttons")
226 if any(type(value) is not int for value in (x, y, buttons)) or not 0 <= buttons <= 255:
227 raise ValueError("Unsupported pointer event.")
228 self.pointer = (max(0, min(x, self.width - 1)), max(0, min(y, self.height - 1)))
229 self.writer.write(struct.pack("!BBHH", 5, buttons, *self.pointer))
230 elif kind == "release":
231 for key in self.keys:
232 self.writer.write(struct.pack("!BB2xI", 4, 0, key))
233 self.keys.clear()
234 self.writer.write(struct.pack("!BBHH", 5, 0, *self.pointer))
235 elif kind == "clipboard":
236 text = message.get("text")
237 if not isinstance(text, str) or len(text) > 65536:
238 raise ValueError("Shorten the clipboard text, then send it again.")
239 try:
240 data = text.encode("latin1")
241 except UnicodeEncodeError:
242 raise ValueError("Some clipboard characters aren't supported by this guest.") from None
243 self.writer.write(struct.pack("!B3xI", 6, len(data)) + data)
244 else:
245 raise ValueError("Unsupported input event.")
246
247 async def read(self, present, resized):
248 while True:
249 kind = (await self.reader.readexactly(1))[0]
250 if kind == 0:
251 _, count = struct.unpack("!BH", await self.reader.readexactly(3))
252 dirty = False
253 for _ in range(count):
254 x, y, width, height, encoding = struct.unpack("!HHHHi", await self.reader.readexactly(12))
255 if encoding == -224:
256 break
257 if encoding == -223:
258 self.resize(width, height)
259 resized()
260 continue
261 if encoding == -239:
262 if width * height > 256 * 256:
263 raise ValueError("The VM sent an unsupported cursor size.")
264 pixels = bytearray(await self.reader.readexactly(width * height * 4))
265 mask = await self.reader.readexactly(((width + 7) // 8) * height)
266 for row in range(height):
267 for col in range(width):
268 offset = (row * width + col) * 4
269 pixels[offset], pixels[offset + 2] = pixels[offset + 2], pixels[offset]
270 pixels[offset + 3] = 255 if mask[row * ((width + 7) // 8) + col // 8] & (128 >> (col % 8)) else 0
271 self.cursor = {"type": "cursor", "source": "qemu", "width": width, "height": height, "x": x, "y": y,
272 "frame": 0, "frames": 1, "duration": 100,
273 "image": png(width, height, pixels) if width and height else None}
274 self.notify(self.cursor)
275 continue
276 if x + width > self.width or y + height > self.height:
277 raise ValueError("The VM sent a screen update outside its display.")
278 if encoding == 0:
279 data = await self.reader.readexactly(width * height * 4)
280 elif encoding == 1:
281 source_x, source_y = struct.unpack("!HH", await self.reader.readexactly(4))
282 if source_x + width > self.width or source_y + height > self.height:
283 raise ValueError("The VM sent a screen copy outside its display.")
284 data = b"".join(self.pixels[((source_y + row) * self.width + source_x) * 4:
285 ((source_y + row) * self.width + source_x + width) * 4] for row in range(height))
286 else:
287 raise ValueError("The VM sent an unsupported screen encoding.")
288 for row in range(height):
289 start = ((y + row) * self.width + x) * 4
290 self.pixels[start:start + width * 4] = data[row * width * 4:(row + 1) * width * 4]
291 dirty = dirty or bool(width and height)
292 if dirty:
293 present()
294 self.request()
295 await self.writer.drain()
296 elif kind == 2:
297 pass
298 elif kind == 3:
299 _, length = struct.unpack("!3sI", await self.reader.readexactly(7))
300 if length > 65536:
301 raise ValueError("The VM sent too much clipboard text.")
302 self.notify({"type": "clipboard", "text": (await self.reader.readexactly(length)).decode("latin1")})
303 else:
304 raise ValueError("The VM sent an unsupported screen message.")
305
306
307class Screen:
308 def __init__(self, writer, desktop):
309 self.loop = asyncio.get_running_loop()
310 self.writer, self.desktop = writer, desktop
311 self.guest = None
312 self.channel = None
313 hardware = Gst.ElementFactory.make("nvh264enc")
314 encoder = "nvh264enc name=encoder preset=p4 tune=ultra-low-latency zerolatency=true bframes=0 bitrate=6000 gop-size=30" if hardware else (
315 "x264enc name=encoder tune=zerolatency speed-preset=ultrafast bitrate=6000 key-int-max=30 bframes=0")
316 width, height = self.desktop.width, self.desktop.height
317 self.pipeline = Gst.parse_launch(
318 f"appsrc name=frames is-live=true format=time do-timestamp=true max-buffers=1 leaky-type=downstream "
319 f"caps=video/x-raw,format=BGRx,width={width},height={height},framerate=30/1 "
320 f"! videoconvert ! videoscale ! capsfilter name=size caps=video/x-raw,format=NV12,width={width + width % 2},height={height + height % 2} "
321 f"! {encoder} ! video/x-h264,profile=constrained-baseline ! h264parse "
322 "! rtph264pay name=pay pt=96 mtu=1200 config-interval=-1 aggregate-mode=zero-latency "
323 "! capsfilter name=codec caps=application/x-rtp,media=video,encoding-name=H264,clock-rate=90000 "
324 "! webrtcbin name=rtc bundle-policy=max-bundle")
325 self.frames = self.pipeline.get_by_name("frames")
326 self.rtc = self.pipeline.get_by_name("rtc")
327 self.ice = self.rtc.get_property("ice-agent")
328 self.ice._ref_sink() # PyGObject sinks the default agent's floating reference, which webrtcbin still owns.
329 self.ice.set_property("min-rtp-port", 50000)
330 self.ice.set_property("max-rtp-port", 50031)
331 if STUN:
332 self.rtc.set_property("stun-server", STUN)
333 self.handlers = [
334 (self.rtc, self.rtc.connect("on-ice-candidate", lambda _, line, candidate: self.loop.call_soon_threadsafe(
335 self.send, {"type": "candidate", "sdpMLineIndex": line, "candidate": candidate}))),
336 (self.rtc, self.rtc.connect("on-data-channel", self.data_channel)),
337 (self.rtc, self.rtc.connect("notify::connection-state", self.connection_state)),
338 ]
339 self.probe = self.frames.get_static_pad("src").add_probe(Gst.PadProbeType.EVENT_UPSTREAM, self.refresh)
340 self.bus = self.pipeline.get_bus()
341 self.bus.add_signal_watch()
342 self.handlers.append((self.bus, self.bus.connect("message::error", self.pipeline_error)))
343 self.pipeline.set_state(Gst.State.PLAYING)
344 self.send({"type": "display", "width": width, "height": height, "encoder": "nvenc" if hardware else "x264"})
345
346 def close(self):
347 self.desktop.notify = None
348 if self.guest:
349 self.guest.screens.discard(self)
350 if not self.guest.screens:
351 GUESTS.pop(self.guest.name, None)
352 self.guest.task.cancel()
353 for element, handler in self.handlers:
354 element.disconnect(handler)
355 self.handlers.clear()
356 self.frames.get_static_pad("src").remove_probe(self.probe)
357 self.bus.remove_signal_watch()
358 self.pipeline.set_state(Gst.State.NULL)
359
360 def send(self, message):
361 if not self.writer.is_closing():
362 self.writer.write(json.dumps(message, separators=(",", ":")).encode() + b"\n")
363 if self.writer.transport.get_write_buffer_size() > 262144:
364 self.writer.close()
365
366 def fail(self, message):
367 self.send({"type": "error", "message": message, "fatal": True})
368 self.writer.close()
369
370 def pipeline_error(self, _, message):
371 error, debug = message.parse_error()
372 logging.error("VM screen stream: %s (%s)", error, debug)
373 self.loop.call_soon_threadsafe(self.fail, "The screen stream stopped. Reconnect to try again.")
374
375 def resize(self):
376 width, height = self.source.width, self.source.height
377 self.frames.set_property("caps", Gst.Caps.from_string(
378 f"video/x-raw,format=BGRx,width={width},height={height},framerate=30/1"))
379 self.pipeline.get_by_name("size").set_property("caps", Gst.Caps.from_string(
380 f"video/x-raw,format=NV12,width={width + width % 2},height={height + height % 2}"))
381 self.send({"type": "display", "width": width, "height": height,
382 "source": "guest" if self.source is self.guest else "qemu",
383 "encoder": "nvenc" if self.pipeline.get_by_name("encoder").get_factory().get_name() == "nvh264enc" else "x264"})
384
385 def refresh(self, _, info):
386 event = info.get_event().get_structure()
387 if event and event.get_name() == "GstForceKeyUnit":
388 self.loop.call_soon_threadsafe(self.present)
389 return Gst.PadProbeReturn.OK
390
391 def connection_state(self, rtc, _):
392 state = rtc.get_property("connection-state")
393 if state == GstWebRTC.WebRTCPeerConnectionState.CONNECTED:
394 self.loop.call_soon_threadsafe(self.present)
395 self.loop.call_soon_threadsafe(self.desktop.request, False)
396 elif state in (GstWebRTC.WebRTCPeerConnectionState.FAILED, GstWebRTC.WebRTCPeerConnectionState.CLOSED):
397 self.loop.call_soon_threadsafe(self.fail, "The screen disconnected. Reconnect to try again.")
398
399 def data_channel(self, _, channel):
400 if channel.get_property("label") != "input" or self.channel:
401 channel.emit("close")
402 return
403 self.channel = channel
404 self.handlers.append((channel, channel.connect("on-message-string", lambda _, message: self.loop.call_soon_threadsafe(self.input, message))))
405
406 def input(self, raw):
407 if self.writer.is_closing():
408 return
409 try:
410 if len(raw) > 131072:
411 raise ValueError("Shorten the clipboard text, then send it again.")
412 message = json.loads(raw)
413 if not isinstance(message, dict):
414 raise ValueError("Unsupported input event.")
415 self.desktop.input(message)
416 if self.desktop.writer.transport.get_write_buffer_size() > 262144:
417 self.fail("The VM stopped accepting input. Reconnect to try again.")
418 except (ValueError, TypeError) as error:
419 self.send({"type": "error", "message": str(error)})
420
421 @property
422 def source(self):
423 return self.guest if self.guest and self.guest.pixels else self.desktop
424
425 def present(self):
426 if self.rtc.get_property("connection-state") == GstWebRTC.WebRTCPeerConnectionState.CONNECTED:
427 buffer = Gst.Buffer.new_wrapped(bytes(self.source.pixels))
428 self.frames.emit("push-buffer", buffer)
429
430 def signal(self, message):
431 if message.get("type") == "offer" and isinstance(message.get("sdp"), str):
432 if self.rtc.get_property("remote-description"):
433 raise ValueError("Reconnect to start a new screen connection.")
434 result, sdp = GstSdp.SDPMessage.new_from_text(message["sdp"])
435 if result != GstSdp.SDPResult.OK:
436 raise ValueError("Unable to connect the screen. Reconnect to try again.")
437 caps = None
438 for index in range(sdp.medias_len()):
439 media = sdp.get_media(index)
440 if media.get_media() != "video":
441 continue
442 for format_index in range(media.formats_len()):
443 payload = int(media.get_format(format_index))
444 offered = media.get_caps_from_media(payload)
445 if not offered:
446 continue
447 codec = offered.get_structure(0)
448 profile = codec.get_string("profile-level-id")
449 if codec.get_string("encoding-name") == "H264" and codec.get_string("packetization-mode") == "1" and (
450 not profile or profile.startswith("42")):
451 caps = offered
452 break
453 if caps:
454 break
455 if not caps:
456 raise ValueError("This browser doesn't support the VM's H.264 stream. Open it in a current browser.")
457 payload = caps.get_structure(0).get_value("payload")
458 caps = Gst.Caps.from_string(
459 f"application/x-rtp,media=video,encoding-name=H264,clock-rate=90000,payload={payload},"
460 "packetization-mode=(string)1,rtcp-fb-nack=(boolean)true,rtcp-fb-nack-pli=(boolean)true")
461 self.pipeline.get_by_name("pay").set_property("pt", payload)
462 self.pipeline.get_by_name("codec").set_property("caps", caps)
463 transceiver = self.rtc.get_static_pad("sink_0").get_property("transceiver")
464 transceiver.set_property("codec-preferences", caps)
465 transceiver.set_property("direction", GstWebRTC.WebRTCRTPTransceiverDirection.SENDONLY)
466 transceiver.set_property("do-nack", True)
467 description = GstWebRTC.WebRTCSessionDescription.new(GstWebRTC.WebRTCSDPType.OFFER, sdp)
468 promise = Gst.Promise.new_with_change_func(self.remote_description, None, None)
469 self.rtc.emit("set-remote-description", description, promise)
470 elif message.get("type") == "candidate":
471 line, candidate = message.get("sdpMLineIndex"), message.get("candidate")
472 if type(line) is not int or not 0 <= line <= 16 or not isinstance(candidate, str):
473 raise ValueError("Unsupported screen connection candidate.")
474 self.rtc.emit("add-ice-candidate", line, candidate)
475 else:
476 raise ValueError("Unsupported screen connection message.")
477
478 def remote_description(self, promise, *_):
479 reply = promise.get_reply()
480 if reply and reply.has_field("error"):
481 logging.error("VM screen offer: %s", reply.to_string())
482 self.loop.call_soon_threadsafe(self.fail, "Unable to connect the screen. Reconnect to try again.")
483 return
484 self.rtc.emit("create-answer", None, Gst.Promise.new_with_change_func(self.answer, None, None))
485
486 def answer(self, promise, *_):
487 reply = promise.get_reply()
488 if not reply or not reply.has_field("answer"):
489 logging.error("VM screen answer: %s", reply.to_string() if reply else "missing reply")
490 self.loop.call_soon_threadsafe(self.fail, "Unable to connect the screen. Reconnect to try again.")
491 return
492 answer = reply.get_value("answer")
493 self.rtc.emit("set-local-description", answer, Gst.Promise.new())
494 self.loop.call_soon_threadsafe(self.send, {"type": "answer", "sdp": answer.sdp.as_text()})
495
496
497async def serve(reader, writer):
498 desktop = screen = task = None
499 if SLOTS.locked():
500 writer.write(b'{"type":"error","fatal":true,"message":"Four VM screens are already open. Close one, then reconnect."}\n')
501 writer.close()
502 return
503 async with SLOTS:
504 try:
505 peer = writer.get_extra_info("socket")
506 if struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] not in (0, os.getuid()):
507 return
508 name = json.loads(await asyncio.wait_for(reader.readline(), 10))["name"]
509 if not isinstance(name, str) or not name or len(name) > 63:
510 raise ValueError("Choose a VM from the list.")
511 host_reader, host_writer = await asyncio.wait_for(asyncio.open_unix_connection(
512 os.environ.get("STUDIO_HOST_SOCKET", "/run/studio-host/host.sock")), 10)
513 desktop = Desktop(host_reader, host_writer, lambda message: screen.send(message) if message["type"] != "cursor" or screen.source is desktop else None)
514 host_peer = host_writer.get_extra_info("socket")
515 if struct.unpack("3i", host_peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] != 0:
516 raise ValueError("The host identity couldn't be verified. Check its configuration.")
517 host_writer.write(json.dumps({"operation": "vm.console", "payload": {"name": name}}).encode() + b"\n")
518 size = struct.unpack("!I", await asyncio.wait_for(host_reader.readexactly(4), 70))[0]
519 if size > 65536:
520 raise ValueError("The host screen response was too large.")
521 result = json.loads(await host_reader.readexactly(size))
522 if "error" in result:
523 raise ValueError(result["error"])
524 await asyncio.wait_for(desktop.start(), 10)
525 screen = Screen(writer, desktop)
526 if name not in GUESTS:
527 GUESTS[name] = GuestDesktop(name)
528 GUESTS[name].task = asyncio.create_task(GUESTS[name].run())
529 screen.guest = GUESTS[name]
530 screen.guest.screens.add(screen)
531 if screen.guest.pixels:
532 screen.resize()
533 if screen.guest.pointer:
534 screen.send(screen.guest.pointer)
535 for cursor in screen.guest.cursor:
536 screen.send(cursor)
537 screen.send({"type": "ready", "iceServers": [{"urls": STUN.replace("stun://", "stun:")}] if STUN else []})
538 task = asyncio.create_task(desktop.read(lambda: screen.present() if screen.source is desktop else None,
539 lambda: screen.resize() if screen.source is desktop else None))
540 def captured(done):
541 if done.cancelled():
542 return
543 error = done.exception()
544 if error:
545 logging.error("VM screen capture: %s", error)
546 screen.fail(str(error) if isinstance(error, ValueError) else
547 "The VM screen stopped. Check that the VM is running, then reconnect.")
548 task.add_done_callback(captured)
549 async with asyncio.timeout(8 * 3600):
550 while raw := await reader.readline():
551 message = json.loads(raw)
552 if not isinstance(message, dict):
553 raise ValueError("Unsupported screen connection message.")
554 screen.signal(message)
555 if task.done():
556 task.result()
557 except ValueError as error:
558 if screen:
559 screen.send({"type": "error", "message": str(error), "fatal": True})
560 else:
561 writer.write(json.dumps({"type": "error", "message": str(error), "fatal": True}).encode() + b"\n")
562 except (OSError, EOFError, TimeoutError, GLib.Error, asyncio.IncompleteReadError):
563 logging.exception("VM screen connection stopped")
564 if screen:
565 screen.send({"type": "error", "fatal": True, "message": "Unable to connect the VM screen. Reconnect to try again."})
566 finally:
567 if task:
568 task.cancel()
569 with contextlib.suppress(asyncio.CancelledError, Exception):
570 await task
571 if screen:
572 screen.close()
573 if desktop:
574 with contextlib.suppress(OSError):
575 desktop.input({"type": "release"})
576 desktop.writer.close()
577 try:
578 await asyncio.wait_for(desktop.writer.wait_closed(), 2)
579 except (OSError, TimeoutError):
580 desktop.writer.transport.abort()
581 writer.close()
582 try:
583 await asyncio.wait_for(writer.wait_closed(), 2)
584 except (OSError, TimeoutError):
585 writer.transport.abort()
586
587
588async def main():
589 Gst.init(None)
590 threading.Thread(target=GLib.MainLoop().run, daemon=True).start()
591 path = Path(os.environ.get("STUDIO_VM_SCREEN_SOCKET", "/run/studio-vm-screen/screen.sock"))
592 path.unlink(missing_ok=True)
593 server = await asyncio.start_unix_server(serve, path=str(path), limit=131072)
594 path.chmod(0o600)
595 async with server:
596 await server.serve_forever()
597
598
599if __name__ == "__main__":
600 asyncio.run(main())
tools/vms.py+25-8
......@@ -20,6 +20,7 @@ PRESETS = Path(os.environ.get("STUDIO_VM_PRESETS_ROOT", str(IMAGES / "Presets"))
2020UPLOADS = Path(os.environ.get("STUDIO_VM_UPLOADS_ROOT", str(IMAGES / "Install Disks")))
2121NS = "{https://paperclover.net/studio}"
2222NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z")
23GUEST_CHANNEL = json.loads((Path(__file__).resolve().parent.parent / "guest/protocol.json").read_text())["channel"]
2324PROFILES = {
2425 "linux-x86-virtio": {
2526 "arch": "x86_64", "machine": "q35", "platform": "linux", "firmware": None,
......@@ -30,7 +31,7 @@ PROFILES = {
3031 "arch": "aarch64", "machine": "virt", "platform": "linux", "firmware": "uefi",
3132 "disk": ("vda", "virtio"), "cd": ("sda", "scsi"), "seed": ("sdb", "scsi"),
3233 "network": "virtio", "usb": "qemu-xhci", "clock": "utc", "emulated": True,
33 "scsi": True,
34 "scsi": True, "video": "virtio", "keyboard": "usb", "serial": "ttyAMA0",
3435 },
3536 "windows-q35-uefi": {
3637 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
......@@ -40,7 +41,8 @@ PROFILES = {
4041 "windows-q35-secure": {
4142 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
4243 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
43 "usb": "qemu-xhci", "clock": "localtime", "tpm": True, "secure": True, "smm": True,
44 "usb": "qemu-xhci", "clock": "utc", "cpu_mode": "host-passthrough",
45 "tpm": True, "secure": True, "smm": True,
4446 },
4547 "windows-q35-bios": {
4648 "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "windows", "firmware": "bios",
......@@ -60,7 +62,7 @@ ACTION_FIELDS = {
6062 "remove": {"name", "disks"},
6163 "library": None, "media": {"name", "image"},
6264 "preset": {"name", "id", "os", "description"},
63 "console": {"name"}, "serial": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"},
65 "console": {"name"}, "serial": {"name"}, "guest": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"},
6466}
6567
6668
......@@ -569,7 +571,7 @@ def guest_access(directory, username, hostname):
569571 return password
570572
571573
572def linux_seed(directory, username, os_name):
574def linux_seed(directory, username, os_name, serial="ttyS0"):
573575 if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root":
574576 raise ValueError("Use an account username suitable for a Linux guest.")
575577 hostname = "snowglobe-vm-" + secrets.token_hex(4)
......@@ -600,7 +602,7 @@ fi
600602 "bootcmd": ([["rm", "-f", gdm_config]] if nixos else []) + [["sh", "-c", rename]],
601603 "chpasswd": {"expire": False, "users": [{"name": user, "password": hashed, "type": "hash"} for user in [username, "root"]]},
602604 "write_files": files,
603 "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", "serial-getty@ttyS0.service"]],
605 "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", f"serial-getty@{serial}.service"]],
604606 }
605607 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
606608 files = Path(temporary)
......@@ -723,7 +725,9 @@ def create(spec):
723725 ET.SubElement(features, "apic")
724726 if details_profile.get("smm"):
725727 ET.SubElement(features, "smm", state="on")
726 if details_profile.get("cpu"):
728 if cpu_mode := details_profile.get("cpu_mode"):
729 ET.SubElement(root, "cpu", mode=cpu_mode, check="none", migratable="on")
730 elif details_profile.get("cpu"):
727731 cpu = ET.SubElement(root, "cpu", mode="custom", match="exact", check="full")
728732 ET.SubElement(cpu, "model", fallback="forbid").text = details_profile["cpu"]
729733 elif root.get("type") == "qemu":
......@@ -747,7 +751,7 @@ def create(spec):
747751 ET.SubElement(cd, "target", dev=details_profile["cd"][0], bus=details_profile["cd"][1])
748752 ET.SubElement(cd, "readonly")
749753 if preset and spec["platform"] == "linux":
750 linux_seed(directory, spec["username"], preset["os"])
754 linux_seed(directory, spec["username"], preset["os"], details_profile.get("serial", "ttyS0"))
751755 seed = ET.SubElement(devices, "disk", type="file", device="cdrom")
752756 ET.SubElement(seed, "driver", name="qemu", type="raw")
753757 ET.SubElement(seed, "source", file=str(directory / "seed.iso"))
......@@ -762,8 +766,13 @@ def create(spec):
762766 ET.SubElement(nic, "source", network="default")
763767 ET.SubElement(nic, "model", type=details_profile["network"])
764768 ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
765 ET.SubElement(ET.SubElement(devices, "video"), "model", type="vga", vram="16384", heads="1", primary="yes")
769 video = {"type": details_profile.get("video", "vga"), "heads": "1", "primary": "yes"}
770 if video["type"] == "vga":
771 video["vram"] = "16384"
772 ET.SubElement(ET.SubElement(devices, "video"), "model", **video)
766773 ET.SubElement(devices, "input", type="tablet", bus="usb")
774 if keyboard := details_profile.get("keyboard"):
775 ET.SubElement(devices, "input", type="keyboard", bus=keyboard)
767776 ET.SubElement(devices, "controller", type="usb", model=details_profile["usb"])
768777 if details_profile.get("tpm"):
769778 tpm = ET.SubElement(devices, "tpm", model="tpm-crb")
......@@ -772,6 +781,7 @@ def create(spec):
772781 ET.SubElement(devices, "memballoon", model=details_profile["balloon"])
773782 ET.SubElement(devices, "console", type="pty")
774783 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))
775785 xml = directory / "domain.xml"
776786 xml.write_bytes(ET.tostring(root))
777787 signal.alarm(0)
......@@ -817,6 +827,13 @@ def main():
817827 return library()
818828 if action == "console":
819829 return console_target(payload["name"])
830 if action == "guest":
831 root = ET.fromstring(virsh("dumpxml", payload["name"]))
832 source = root.find(f"./devices/channel[@type='unix']/target[@name='{GUEST_CHANNEL}']/../source")
833 expected = f"/run/libvirt/qemu/channel/{root.get('id')}-{payload['name']}/{GUEST_CHANNEL}"
834 if virsh("domstate", payload["name"]).strip() != "running" or source is None or source.get("mode") != "bind" or source.get("path") != expected:
835 raise ValueError("This VM has no guest display channel.")
836 return {"path": expected}
820837 if action == "serial":
821838 if virsh("domstate", payload["name"]).strip() not in {"running", "blocked"}:
822839 raise ValueError("Start this VM before opening its console.")