1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import re
6import secrets
7import shutil
8import signal
9import stat
10import tempfile
11import time
12import subprocess
13import sys
14import xml.etree.ElementTree as ET
15
16
17DISKS = Path("/srv/vm")
18IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm"))
19PRESETS = Path(os.environ.get("STUDIO_VM_PRESETS_ROOT", str(IMAGES / "Presets")))
20UPLOADS = Path(os.environ.get("STUDIO_VM_UPLOADS_ROOT", str(IMAGES / "Install Disks")))
21NS = "{https://paperclover.net/studio}"
22NAME = 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"]
24PROFILES = {
25 "linux-x86-virtio": {
26 "arch": "x86_64", "machine": "q35", "platform": "linux", "firmware": None,
27 "disk": ("vda", "virtio"), "cd": ("sdb", "sata"), "seed": ("sdc", "sata"),
28 "network": "virtio", "usb": "qemu-xhci", "clock": "utc",
29 },
30 "linux-aarch64-virtio": {
31 "arch": "aarch64", "machine": "virt", "platform": "linux", "firmware": "uefi",
32 "disk": ("vda", "virtio"), "cd": ("sda", "scsi"), "seed": ("sdb", "scsi"),
33 "network": "virtio", "usb": "qemu-xhci", "clock": "utc", "emulated": True,
34 "scsi": True, "video": "virtio", "keyboard": "usb", "serial": "ttyAMA0",
35 },
36 "windows-q35-uefi": {
37 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
38 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
39 "usb": "qemu-xhci", "clock": "localtime", "tpm": True,
40 },
41 "windows-q35-secure": {
42 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
43 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
44 "usb": "qemu-xhci", "clock": "utc", "cpu_mode": "host-passthrough",
45 "tpm": True, "secure": True, "smm": True,
46 },
47 "windows-q35-bios": {
48 "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "windows", "firmware": "bios",
49 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
50 "usb": "qemu-xhci", "clock": "localtime", "balloon": "none",
51 },
52 "windows-legacy-ide": {
53 "arch": "x86_64", "machine": "pc-i440fx-10.2", "platform": "windows", "firmware": "bios",
54 "disk": ("hda", "ide"), "cd": ("hdc", "ide"), "network": "e1000",
55 "usb": "piix3-uhci", "clock": "localtime", "balloon": "none",
56 },
57}
58ACTION_FIELDS = {
59 "node": None, "domains": None, "stats": None,
60 "create": {"name", "description", "image", "vcpus", "memory", "disk", "autostart", "start", "mode", "firmware", "platform", "owner", "username"},
61 "act": {"name", "action"}, "update": {"name", "description", "autostart"},
62 "remove": {"name", "disks"},
63 "library": None, "media": {"name", "image"},
64 "preset": {"name", "id", "os", "description"},
65 "console": {"name"}, "serial": {"name"}, "guest": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"},
66}
67
68
69def profile(name):
70 try:
71 return PROFILES[name]
72 except KeyError as error:
73 raise ValueError("This VM needs a supported hardware profile.") from error
74
75
76def default_profile(platform, firmware, arch="x86_64"):
77 if arch == "aarch64":
78 if platform != "linux":
79 raise ValueError("Windows ARM needs a tested ARM hardware profile.")
80 return "linux-aarch64-virtio"
81 if platform == "windows":
82 return "windows-q35-uefi" if firmware == "uefi" else "windows-q35-bios"
83 return "linux-x86-virtio"
84
85
86def installer_profile(source, spec):
87 if source:
88 name = source.name.lower()
89 if "windows xp" in name or "windows vista" in name or "windows 7" in name:
90 return "windows-legacy-ide"
91 if "windows 8" in name:
92 return "windows-q35-bios"
93 if "windows 11" in name and architecture(name) == "x86_64":
94 return "windows-q35-secure"
95 arch = architecture(name)
96 else:
97 arch = "x86_64"
98 return default_profile(spec["platform"], spec["firmware"], arch)
99
100
101def inferred_profile(root):
102 stored = root.findtext(f"metadata/{NS}vm/{NS}hardwareProfile")
103 if stored:
104 return stored
105 os_element = root.find("os")
106 type_element = os_element.find("type") if os_element is not None else None
107 arch = type_element.get("arch", "x86_64") if type_element is not None else "x86_64"
108 machine = type_element.get("machine", "") if type_element is not None else ""
109 platform = root.findtext(f"metadata/{NS}vm/{NS}platform", "linux")
110 firmware = "uefi" if (os_element is not None and (os_element.find("loader") is not None or os_element.get("firmware") == "efi")) else "bios"
111 if machine.startswith("pc-i440fx"):
112 return "windows-legacy-ide"
113 if arch == "aarch64":
114 return "linux-aarch64-virtio"
115 return default_profile(platform, firmware, arch)
116
117
118def checked_profile(name, platform, firmware, arch):
119 result = profile(name)
120 if (result["platform"] != platform or result["arch"] != arch
121 or (result["firmware"] and result["firmware"] != firmware)):
122 raise ValueError("This preset needs to be prepared again.")
123 return result
124
125
126def node():
127 memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
128 return {"cpus": os.cpu_count(), "memory": memory}
129
130
131def validate(action, payload):
132 if action not in ACTION_FIELDS:
133 raise ValueError("Choose a supported VM action.")
134 fields = ACTION_FIELDS[action]
135 if fields is None:
136 if payload is not None:
137 raise ValueError("This VM query doesn't take any fields.")
138 return
139 if (not isinstance(payload, dict) or not set(payload) <= fields
140 or (action != "update" and set(payload) != fields)
141 or (action == "update" and ("name" not in payload or len(payload) < 2))):
142 raise ValueError("Use only the fields required by this VM action.")
143 if action == "upload":
144 volume = payload["volume"]
145 if (not isinstance(volume, str) or not volume.lower().endswith(".iso")
146 or len(volume) > 200 or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9 ._()-]*", volume)):
147 raise ValueError("Choose an ISO with a simple filename ending in .iso.")
148 if type(payload["size"]) is not int or not 32768 <= payload["size"] <= 32 * 2**30:
149 raise ValueError("Choose an ISO between 32 KiB and 32 GiB.")
150 return
151 if "name" not in payload:
152 raise ValueError("Enter a VM name.")
153 ensure_name(payload["name"])
154 if "description" in payload and (not isinstance(payload["description"], str) or len(payload["description"]) > 200):
155 raise ValueError("Keep the description under 200 characters.")
156 for field in ("autostart", "start", "disks"):
157 if field in payload and not isinstance(payload[field], bool):
158 raise ValueError(f"Choose whether to enable {field}.")
159 if action == "act" and (not isinstance(payload["action"], str) or payload["action"] not in {"start", "shutdown", "reboot", "destroy", "resume"}):
160 raise ValueError("Choose a supported VM action.")
161 if action == "preset":
162 ensure_name(payload["id"])
163 if not isinstance(payload["os"], str) or not 1 <= len(payload["os"]) <= 100:
164 raise ValueError("Enter the operating system's name.")
165 if action == "media":
166 validate_image_id(payload["image"], empty=True)
167 if action == "create":
168 if not isinstance(payload["owner"], str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", payload["owner"]):
169 raise ValueError("Choose a signed-in account to own this VM.")
170 if not isinstance(payload["username"], str) or not 1 <= len(payload["username"]) <= 128:
171 raise ValueError("Choose a signed-in account to create this VM.")
172 if payload["mode"] not in {"iso", "preset"}:
173 raise ValueError("Choose an installer or a prepared preset.")
174 if payload["firmware"] not in {"bios", "uefi"} or payload["platform"] not in {"linux", "windows"}:
175 raise ValueError("Choose supported firmware and guest hardware.")
176 image = payload["image"]
177 validate_image_id(image)
178 host = node()
179 for field, minimum, maximum in [("vcpus", 1, host["cpus"]), ("memory", 2**29, host["memory"]), ("disk", 2**30, 2**63 - 1)]:
180 if type(payload[field]) is not int or not minimum <= payload[field] <= maximum:
181 raise ValueError(f"Choose {field} within the host's supported range.")
182 if DISKS.is_symlink() or (DISKS / payload["name"]).is_symlink():
183 raise ValueError("The VM disk directory is a symbolic link. Remove the link before continuing.")
184
185
186def command(*args, pass_fds=()):
187 return subprocess.run(args, check=True, text=True, capture_output=True, pass_fds=pass_fds).stdout.strip()
188
189
190def virsh(*args):
191 return command("virsh", "-c", "qemu:///system", *args)
192
193
194def info(file):
195 return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file)))
196
197
198def standalone(details):
199 return (details.get("format") in {"raw", "qcow2"} and not details.get("backing-filename")
200 and not details.get("format-specific", {}).get("data", {}).get("data-file"))
201
202
203def disk(file, target, pool):
204 details = info(file) if file.is_file() else {}
205 return {
206 "target": target,
207 "pool": pool,
208 "source": file.name if pool else str(file),
209 "capacity": details.get("virtual-size", file.stat().st_size if file.exists() else 0),
210 "allocation": details.get("actual-size", 0),
211 }
212
213
214def image(file):
215 os_name = file.stem.replace("_", " ").replace("-", " ")
216 windows = "windows" in os_name.lower()
217 return {"volume": image_id(file), "os": os_name, "arch": architecture(file.name),
218 "capacity": file.stat().st_size,
219 "recommended": {"vcpus": 4 if windows else 2, "memory": 4 * 2**30,
220 "disk": (64 if windows else 32) * 2**30}}
221
222
223def domains():
224 result = []
225 for name in virsh("list", "--all", "--name").splitlines():
226 if not name:
227 continue
228 root = ET.fromstring(virsh("dumpxml", name))
229 state_line = virsh("domstate", name, "--reason").splitlines()[0].lower()
230 state = next((value for value in ("running", "blocked", "paused", "shutdown", "crashed", "pmsuspended") if state_line.startswith(value)), "shutoff")
231 reason = state_line.split("(", 1)[1].rstrip(")") if state in {"paused", "crashed"} and "(" in state_line else None
232 memory = int(root.findtext("memory", "0")) * 1024
233 balloon = int(root.findtext("currentMemory", str(memory // 1024))) * 1024
234 disks = []
235 media = []
236 for element in root.findall("./devices/disk"):
237 if element.findtext("serial") == "studio-cloud-init":
238 continue
239 source = element.find("source")
240 target = element.find("target")
241 if target is not None and element.get("device") == "cdrom":
242 media.append({"target": target.get("dev", ""),
243 "source": Path(source.get("file")).name if source is not None and source.get("file") else None})
244 continue
245 if source is None or target is None:
246 continue
247 file = source.get("file") or source.get("dev")
248 if not file:
249 continue
250 target_name = target.get("dev", "")
251 pool = "vms" if Path(file).is_relative_to(DISKS / name) else None
252 disks.append(disk(Path(file), target_name, pool))
253 interfaces = []
254 for element in root.findall("./devices/interface"):
255 mac = element.find("mac")
256 source = element.find("source")
257 interfaces.append({
258 "mac": mac.get("address", "") if mac is not None else "",
259 "source": source.get("network", source.get("bridge", "")) if source is not None else "",
260 "addresses": [],
261 })
262 if state == "running":
263 for line in virsh("domifaddr", name, "--source", "lease").splitlines():
264 fields = line.split()
265 if len(fields) >= 4:
266 interface = next((item for item in interfaces if item["mac"].lower() == fields[1].lower()), None)
267 if interface:
268 interface["addresses"].append(fields[3].split("/", 1)[0])
269 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
270 started = None
271 if state == "running" and pid_file.exists():
272 pid = pid_file.read_text().strip()
273 boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text().splitlines() if line.startswith("btime "))
274 ticks = int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19])
275 started = boot + ticks / os.sysconf("SC_CLK_TCK")
276 agent = root.find("./devices/channel/target[@name='org.qemu.guest_agent.0']")
277 result.append({
278 "name": name,
279 "owner": root.findtext(f"metadata/{NS}vm/{NS}owner"),
280 "description": root.findtext("description", ""),
281 "state": state,
282 "reason": reason,
283 "os": root.findtext(f"metadata/{NS}vm/{NS}os", root.findtext(f"metadata/{NS}os", "Other")),
284 "vcpus": int(root.findtext("vcpu", "1")),
285 "pinned": None,
286 "memory": memory,
287 "balloon": balloon,
288 "autostart": re.search(r"^Autostart:\s+enable", virsh("dominfo", name), re.MULTILINE) is not None,
289 "startedAt": started,
290 "agent": agent.get("state", "disconnected") if agent is not None else None,
291 "disks": disks,
292 "media": media,
293 "firmware": "uefi" if root.find("./os/loader") is not None or root.find("os").get("firmware") == "efi" else "bios",
294 "platform": root.findtext(f"metadata/{NS}vm/{NS}platform", "linux"),
295 "console": root.find("./devices/graphics[@type='vnc']") is not None,
296 "serial": root.find("./devices/console[@type='pty']/target[@type='serial']") is not None,
297 "interfaces": interfaces,
298 "hostdevs": [],
299 })
300 return result
301
302
303def ensure_name(name):
304 if not isinstance(name, str) or not NAME.fullmatch(name):
305 raise ValueError("invalid VM name")
306
307
308def ensure_network():
309 if re.search(r"^Active:\s+no", virsh("net-info", "default"), re.MULTILINE):
310 virsh("net-start", "default")
311 virsh("net-autostart", "default")
312
313
314def validate_image_id(value, empty=False):
315 if not isinstance(value, str) or len(value) > 512 or (not value and not empty):
316 raise ValueError("Choose an installer from the library.")
317 if value in {"", "blank"}:
318 return
319 relative = value.split(":", 1)[1] if value.startswith(("media:", "upload:")) else value
320 if relative.startswith("/") or any(part in {"", ".", ".."} for part in relative.split("/")):
321 raise ValueError("Choose an installer from the library.")
322
323
324def open_regular(file):
325 # Walk with directory FDs so replacing any parent with a symlink cannot escape.
326 parent = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
327 try:
328 for part in file.parts[1:-1]:
329 child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
330 os.close(parent)
331 parent = child
332 fd = os.open(file.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
333 if not stat.S_ISREG(os.fstat(fd).st_mode):
334 os.close(fd)
335 raise ValueError("Choose a regular image file.")
336 return fd
337 finally:
338 os.close(parent)
339
340
341def resolve_image(value):
342 validate_image_id(value)
343 if value.startswith("upload:"):
344 file = UPLOADS / value.removeprefix("upload:")
345 else:
346 file = IMAGES / value.removeprefix("media:")
347 if file.suffix.lower() != ".iso":
348 raise ValueError("Choose a supported installer or disk image.")
349 try:
350 os.close(open_regular(file))
351 except OSError as error:
352 raise ValueError("This image is unavailable. Refresh the library and choose another.") from error
353 return file
354
355
356def architecture(name):
357 text = name.lower()
358 return "aarch64" if any(word in text for word in ("arm64", "aarch64")) else "x86_64"
359
360
361def read_preset(identity):
362 ensure_name(identity)
363 directory = PRESETS / identity
364 try:
365 with os.fdopen(open_regular(directory / "preset.json")) as incoming:
366 result = json.load(incoming)
367 with os.fdopen(open_regular(directory / "disk.qcow2"), "rb") as incoming:
368 details = json.loads(command("qemu-img", "info", "-U", "--output=json", f"/proc/self/fd/{incoming.fileno()}", pass_fds=(incoming.fileno(),)))
369 if not standalone(details) or result["firmware"] not in {"bios", "uefi"} or result["platform"] not in {"linux", "windows"}:
370 raise ValueError("This preset needs to be prepared again.")
371 result.setdefault("arch", "x86_64")
372 result.setdefault("hardwareProfile", default_profile(result["platform"], result["firmware"], result["arch"]))
373 checked_profile(result["hardwareProfile"], result["platform"], result["firmware"], result["arch"])
374 result["capacity"] = details["virtual-size"]
375 return result
376 except (OSError, KeyError, json.JSONDecodeError) as error:
377 raise ValueError("This preset is unavailable. Refresh the library and choose another.") from error
378
379
380def image_id(file):
381 if file.is_relative_to(IMAGES):
382 return "media:" + file.relative_to(IMAGES).as_posix()
383 return "upload:" + file.relative_to(UPLOADS).as_posix()
384
385
386def library():
387 installers = []
388 roots = [IMAGES]
389 if not UPLOADS.is_relative_to(IMAGES):
390 roots.append(UPLOADS)
391 for base in roots:
392 if not base.is_dir() or base.is_symlink():
393 continue
394 for root, directories, files in os.walk(base, followlinks=False):
395 directories[:] = [name for name in directories if not (Path(root) / name).is_symlink() and not name.startswith(".") and name != "Presets"]
396 for name in files:
397 file = Path(root) / name
398 if file.is_symlink() or file.suffix.lower() != ".iso":
399 continue
400 installers.append(image(file))
401 presets = []
402 directory = PRESETS
403 if directory.is_dir() and not directory.is_symlink():
404 for file in sorted(directory.iterdir()):
405 if not file.is_dir() or file.is_symlink() or not NAME.fullmatch(file.name):
406 continue
407 try:
408 presets.append(read_preset(file.name))
409 except (ValueError, subprocess.CalledProcessError):
410 continue
411 return {"installers": sorted(installers, key=lambda item: item["os"].lower()), "presets": presets, "arch": "x86_64"}
412
413
414def managed_directory(path):
415 path.mkdir(parents=True, exist_ok=True)
416 fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
417 try:
418 for part in path.parts[1:]:
419 child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
420 os.close(fd)
421 fd = child
422 finally:
423 os.close(fd)
424
425
426def change_media(spec):
427 name = spec["name"]
428 root = ET.fromstring(virsh("dumpxml", name, "--inactive"))
429 drives = root.findall("./devices/disk[@device='cdrom']")
430 source = resolve_image(spec["image"]) if spec["image"] else None
431 details = profile(inferred_profile(root))
432 if source and architecture(source.name) != details["arch"]:
433 raise ValueError(f"Choose a {details['arch']} ISO for this VM.")
434 directory = DISKS / name
435 managed_directory(directory)
436 frozen = None
437 if source:
438 fd, temporary = tempfile.mkstemp(prefix=source.stem[:70] + "-", suffix=".iso", dir=directory)
439 frozen = Path(temporary)
440 try:
441 with os.fdopen(open_regular(source), "rb") as incoming, os.fdopen(fd, "wb") as outgoing:
442 shutil.copyfileobj(incoming, outgoing)
443 except BaseException:
444 frozen.unlink(missing_ok=True)
445 raise
446 cd = drives[0] if drives else ET.Element("disk", type="file", device="cdrom")
447 target = cd.find("target")
448 if target is None:
449 ET.SubElement(cd, "driver", name="qemu", type="raw")
450 ET.SubElement(cd, "target", dev=details["cd"][0], bus=details["cd"][1])
451 ET.SubElement(cd, "readonly")
452 for element in cd.findall("source"):
453 cd.remove(element)
454 if frozen:
455 ET.SubElement(cd, "source", file=str(frozen))
456 flags = ["--config"]
457 if virsh("domstate", name).strip() != "shut off":
458 if not drives:
459 if frozen:
460 frozen.unlink(missing_ok=True)
461 raise ValueError("Shut down this VM before adding its first CD drive.")
462 flags.append("--live")
463 xml = directory / "media.xml"
464 xml.write_bytes(ET.tostring(cd))
465 try:
466 virsh("update-device" if drives else "attach-device", name, str(xml), *flags)
467 except BaseException:
468 # A live/config update can partially succeed. Keep media referenced by either XML.
469 if frozen and str(frozen) not in virsh("dumpxml", name) + virsh("dumpxml", name, "--inactive"):
470 frozen.unlink(missing_ok=True)
471 raise
472 # Only collect managed optical images no longer referenced by either definition.
473 references = virsh("dumpxml", name) + virsh("dumpxml", name, "--inactive")
474 for file in directory.glob("*.iso"):
475 if str(file) not in references:
476 file.unlink()
477
478
479def save_preset(spec):
480 name = spec["name"]
481 if virsh("domstate", name).strip() != "shut off":
482 raise ValueError("Shut down this VM before saving a preset.")
483 root = ET.fromstring(virsh("dumpxml", name, "--inactive"))
484 disks = root.findall("./devices/disk[@device='disk']")
485 if len(disks) != 1 or root.findall("./devices/hostdev"):
486 raise ValueError("Use a VM with one disk and no passed-through devices.")
487 source = disks[0].find("source")
488 if source is None or not source.get("file"):
489 raise ValueError("Use a VM with a managed disk.")
490 file = Path(source.get("file"))
491 if not file.is_relative_to(DISKS / name):
492 raise ValueError("Use a disk stored with this VM.")
493 details = info(file)
494 if not standalone(details):
495 raise ValueError("Use a standalone disk without external data files.")
496 base = PRESETS
497 managed_directory(base)
498 target = base / spec["id"]
499 if target.exists():
500 raise ValueError("A preset already has this name. Choose another name.")
501 temporary = Path(tempfile.mkdtemp(prefix=".preparing-", dir=base))
502 try:
503 # Copy from a verified FD; never follow a swapped symlink to a host file.
504 with os.fdopen(open_regular(file), "rb") as incoming:
505 command("cp", "--reflink=auto", "--sparse=always", f"/proc/self/fd/{incoming.fileno()}", str(temporary / "source"), pass_fds=(incoming.fileno(),))
506 if not standalone(info(temporary / "source")):
507 raise ValueError("Use a standalone disk without external data files.")
508 command("qemu-img", "convert", "-O", "qcow2", str(temporary / "source"), str(temporary / "disk.qcow2"))
509 (temporary / "source").unlink()
510 os_element = root.find("os")
511 firmware = "uefi" if root.find("./os/loader") is not None or os_element.get("firmware") == "efi" else "bios"
512 platform = root.findtext(f"metadata/{NS}vm/{NS}platform", "linux")
513 hardware_profile = inferred_profile(root)
514 type_element = os_element.find("type")
515 arch = type_element.get("arch", "x86_64")
516 details_profile = checked_profile(hardware_profile, platform, firmware, arch)
517 # Persistent firmware variables are needed for installers that only register an EFI boot entry.
518 nvram = root.findtext("./os/nvram")
519 if nvram and not details_profile.get("secure"):
520 with os.fdopen(open_regular(Path(nvram)), "rb") as incoming, (temporary / "nvram.fd").open("xb") as outgoing:
521 shutil.copyfileobj(incoming, outgoing)
522 preset = {"id": spec["id"], "os": spec["os"], "description": spec["description"],
523 "firmware": firmware, "platform": platform, "arch": arch, "hardwareProfile": hardware_profile,
524 "capacity": details["virtual-size"], "createdAt": int(time.time()),
525 "recommended": {"vcpus": int(root.findtext("vcpu", "2")),
526 "memory": int(root.findtext("memory")) * 1024, "disk": details["virtual-size"]}}
527 (temporary / "preset.json").write_text(json.dumps(preset))
528 # rename won't overwrite a nonempty preset, including a concurrent publication.
529 temporary.rename(target)
530 finally:
531 if temporary.exists():
532 shutil.rmtree(temporary)
533
534
535def console_target(name):
536 root = ET.fromstring(virsh("dumpxml", name))
537 graphics = root.find("./devices/graphics[@type='vnc']")
538 if virsh("domstate", name).strip() not in {"running", "paused", "blocked"} or graphics is None:
539 raise ValueError("Start this VM before opening its screen.")
540 # Only libvirt's loopback VNC listener; callers cannot provide a host or port.
541 if graphics.get("listen") != "127.0.0.1" or graphics.get("socket"):
542 raise ValueError("Configure this VM's VNC screen to listen on 127.0.0.1.")
543 port = int(graphics.get("port", "-1"))
544 if not 5900 <= port <= 65535:
545 raise ValueError("This VM's screen is not ready. Try again shortly.")
546 return {"port": port, "uuid": root.findtext("uuid")}
547
548
549def secure_firmware():
550 code = Path(os.environ.get("STUDIO_VM_SECURE_OVMF_CODE", ""))
551 variables = Path(os.environ.get("STUDIO_VM_SECURE_OVMF_VARS", ""))
552 if not code.is_absolute() or not variables.is_absolute():
553 raise ValueError("Secure UEFI firmware is unavailable on this host.")
554 try:
555 for file in [code, variables]:
556 os.close(open_regular(file))
557 except OSError as error:
558 raise ValueError("Secure UEFI firmware is unavailable on this host.") from error
559 return code, variables
560
561
562def copy_regular(source, target):
563 with os.fdopen(open_regular(source), "rb") as incoming, target.open("xb") as outgoing:
564 shutil.copyfileobj(incoming, outgoing)
565
566
567def guest_access(directory, username, hostname):
568 password = secrets.token_urlsafe(18)
569 with open(directory / "access.json", "x", opener=lambda path, flags: os.open(path, flags, 0o600)) as outgoing:
570 json.dump({"username": username, "password": password, "hostname": hostname}, outgoing)
571 return password
572
573
574def linux_seed(directory, username, os_name, serial="ttyS0"):
575 if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root":
576 raise ValueError("Use an account username suitable for a Linux guest.")
577 hostname = "snowglobe-vm-" + secrets.token_hex(4)
578 password = guest_access(directory, username, hostname)
579 hashed = subprocess.run(["openssl", "passwd", "-6", "-stdin"], input=password + "\n", text=True, capture_output=True, check=True).stdout.strip()
580 fedora = "fedora" in os_name.lower()
581 nixos = "nixos" in os_name.lower()
582 account_tool = "/run/current-system/sw/bin/" if nixos else ""
583 rename = f'''set -eu
584old=$({account_tool}getent passwd 1000 | cut -d: -f1)
585test -n "$old"
586if [ "$old" != {username} ]; then
587 ! {account_tool}getent passwd {username}
588 {account_tool}usermod -l {username} -c {username} -d /home/{username} -m "$old"
589 if [ "$({account_tool}getent group 1000 | cut -d: -f1)" = "$old" ]; then {account_tool}groupmod -n {username} "$old"; fi
590fi
591'''
592 gdm_config = "/etc/gdm/custom.conf" if fedora or nixos else "/etc/gdm3/custom.conf"
593 files = [
594 {"path": gdm_config, "content": f"[daemon]\nAutomaticLoginEnable=true\nAutomaticLogin={username}\n"},
595 ]
596 if not nixos:
597 files.insert(0, {"path": "/etc/ssh/sshd_config.d/00-snowglobe.conf", "permissions": "0600", "content": "PasswordAuthentication yes\nPermitRootLogin yes\n"})
598 else:
599 files.append({"path": "/var/lib/snowglobe/hostname", "content": hostname + "\n"})
600 config = {
601 "users": [], "disable_root": False, "ssh_pwauth": True, "ssh_deletekeys": True,
602 "bootcmd": ([["rm", "-f", gdm_config]] if nixos else []) + [["sh", "-c", rename]],
603 "chpasswd": {"expire": False, "users": [{"name": user, "password": hashed, "type": "hash"} for user in [username, "root"]]},
604 "write_files": files,
605 "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", f"serial-getty@{serial}.service"]],
606 }
607 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
608 files = Path(temporary)
609 (files / "user-data").write_text("#cloud-config\n" + json.dumps(config))
610 (files / "meta-data").write_text(json.dumps({"instance-id": secrets.token_hex(16), "local-hostname": hostname}))
611 command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "cidata", "-joliet", "-rock", str(files / "user-data"), str(files / "meta-data"))
612
613
614def windows_seed(directory, username):
615 if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,19}", username):
616 raise ValueError("Use an account username suitable for a Windows guest.")
617 hostname = "sgvm-" + secrets.token_hex(4)
618 password = guest_access(directory, username, hostname)
619 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
620 marker = Path(temporary) / "SNOWGLOB.INI"
621 marker.write_text(f"[snowglobe]\nUSERNAME={username}\nPASSWORD={password}\nHOSTNAME={hostname}\n")
622 command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "SNOWGLOBE", "-joliet", "-rock", str(marker))
623
624
625def create(spec):
626 name = spec["name"]
627 ensure_name(name)
628 if name in virsh("list", "--all", "--name").splitlines():
629 raise ValueError("VM already exists")
630 selected = spec["image"]
631 mode = spec["mode"]
632 preset = None
633 if mode == "preset":
634 ensure_name(selected)
635 preset = read_preset(selected)
636 source = PRESETS / selected / "disk.qcow2"
637 profile_name = preset["hardwareProfile"]
638 spec = {**spec, "firmware": preset["firmware"], "platform": preset["platform"]}
639 else:
640 source = resolve_image(selected) if selected != "blank" else None
641 profile_name = installer_profile(source, spec)
642 details_profile = profile(profile_name)
643 spec = {**spec, "firmware": details_profile["firmware"] or spec["firmware"], "platform": details_profile["platform"]}
644 if not preset and source and architecture(source.name) != details_profile["arch"]:
645 raise ValueError(f"Choose a {details_profile['arch']} ISO for this hardware profile.")
646 if mode == "preset" and spec["disk"] < preset["capacity"]:
647 raise ValueError("Choose a disk at least as large as the preset.")
648 if "vms" not in virsh("pool-list", "--all", "--name").splitlines():
649 DISKS.mkdir(parents=True, exist_ok=True)
650 virsh("pool-define-as", "vms", "dir", "--target", str(DISKS))
651 if re.search(r"^State:\s+inactive", virsh("pool-info", "vms"), re.MULTILINE):
652 virsh("pool-start", "vms")
653 virsh("pool-autostart", "vms")
654 directory = DISKS / name
655 directory.mkdir(parents=True, exist_ok=False)
656 drive = directory / "disk.qcow2"
657
658 def expired(_signum, _frame):
659 raise TimeoutError("VM image preparation timed out.")
660
661 previous = signal.signal(signal.SIGALRM, expired)
662 signal.alarm(840)
663 try:
664 os_name = "Other"
665 if source:
666 source_fd = open_regular(source)
667 frozen = directory / (source.name if source.suffix.lower() == ".iso" else "source-image")
668 with os.fdopen(source_fd, "rb") as incoming:
669 if source.suffix.lower() == ".iso":
670 with frozen.open("xb") as outgoing:
671 shutil.copyfileobj(incoming, outgoing)
672 else:
673 command("cp", "--reflink=auto", "--sparse=always", f"/proc/self/fd/{incoming.fileno()}", str(frozen), pass_fds=(incoming.fileno(),))
674 details = info(frozen) if source.suffix.lower() != ".iso" else {}
675 if source.suffix.lower() != ".iso" and not standalone(details):
676 raise ValueError("Use a standalone raw or QCOW2 image without external data files.")
677 os_name = preset["os"] if preset else source.stem.replace("_", " ").replace("-", " ")
678 if details.get("virtual-size", 0) > spec["disk"]:
679 raise ValueError("Choose a disk at least as large as the OS image.")
680 source = frozen
681 if source and source.suffix.lower() != ".iso":
682 command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive))
683 if spec["disk"] > info(drive)["virtual-size"]:
684 command("qemu-img", "resize", str(drive), str(spec["disk"]))
685 source.unlink()
686 else:
687 command("qemu-img", "create", "-f", "qcow2", str(drive), str(spec["disk"]))
688 virsh("pool-refresh", "vms")
689 domain_type = "qemu" if details_profile.get("emulated") or os.environ.get("STUDIO_VM_ACCEL") == "qemu" else "kvm"
690 root = ET.Element("domain", type=domain_type)
691 ET.SubElement(root, "name").text = name
692 ET.SubElement(root, "description").text = spec["description"]
693 metadata = ET.SubElement(ET.SubElement(root, "metadata"), NS + "vm")
694 ET.SubElement(metadata, NS + "os").text = os_name
695 ET.SubElement(metadata, NS + "platform").text = spec["platform"]
696 ET.SubElement(metadata, NS + "hardwareProfile").text = profile_name
697 ET.SubElement(metadata, NS + "owner").text = spec["owner"]
698 ET.SubElement(root, "memory", unit="bytes").text = str(spec["memory"])
699 ET.SubElement(root, "vcpu").text = str(spec["vcpus"])
700 firmware = spec["firmware"]
701 os_element = ET.SubElement(root, "os")
702 if firmware == "uefi":
703 if details_profile.get("secure"):
704 code, variables = secure_firmware()
705 nvram = directory / "nvram.fd"
706 copy_regular(variables, nvram)
707 ET.SubElement(os_element, "loader", readonly="yes", type="pflash", secure="yes").text = str(code)
708 ET.SubElement(os_element, "nvram", template=str(variables), templateFormat="raw", format="raw").text = str(nvram)
709 else:
710 os_element.set("firmware", "efi")
711 if preset and (PRESETS / selected / "nvram.fd").exists():
712 nvram = directory / "nvram.fd"
713 copy_regular(PRESETS / selected / "nvram.fd", nvram)
714 ET.SubElement(os_element, "nvram").text = str(nvram)
715 features = ET.SubElement(os_element, "firmware")
716 ET.SubElement(features, "feature", enabled="no", name="secure-boot")
717 ET.SubElement(os_element, "type", arch=details_profile["arch"], machine=details_profile["machine"]).text = "hvm"
718 ET.SubElement(os_element, "boot", dev="cdrom" if source and source.suffix.lower() == ".iso" else "hd")
719 if source and source.suffix.lower() == ".iso":
720 ET.SubElement(os_element, "boot", dev="hd")
721 ET.SubElement(os_element, "bootmenu", enable="yes", timeout="5000")
722 features = ET.SubElement(root, "features")
723 ET.SubElement(features, "acpi")
724 if details_profile["arch"] == "x86_64":
725 ET.SubElement(features, "apic")
726 if details_profile.get("smm"):
727 ET.SubElement(features, "smm", state="on")
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"):
731 cpu = ET.SubElement(root, "cpu", mode="custom", match="exact", check="full")
732 ET.SubElement(cpu, "model", fallback="forbid").text = details_profile["cpu"]
733 elif root.get("type") == "qemu":
734 ET.SubElement(root, "cpu", mode="maximum")
735 ET.SubElement(root, "clock", offset=details_profile["clock"])
736 ET.SubElement(root, "on_poweroff").text = "destroy"
737 ET.SubElement(root, "on_reboot").text = "restart"
738 ET.SubElement(root, "on_crash").text = "destroy"
739 devices = ET.SubElement(root, "devices")
740 ET.SubElement(devices, "emulator").text = f"/run/current-system/sw/bin/qemu-system-{details_profile['arch']}"
741 if details_profile.get("scsi"):
742 ET.SubElement(devices, "controller", type="scsi", index="0", model="virtio-scsi")
743 primary = ET.SubElement(devices, "disk", type="file", device="disk")
744 ET.SubElement(primary, "driver", name="qemu", type="qcow2")
745 ET.SubElement(primary, "source", file=str(drive))
746 ET.SubElement(primary, "target", dev=details_profile["disk"][0], bus=details_profile["disk"][1])
747 cd = ET.SubElement(devices, "disk", type="file", device="cdrom")
748 ET.SubElement(cd, "driver", name="qemu", type="raw")
749 if source and source.suffix.lower() == ".iso":
750 ET.SubElement(cd, "source", file=str(source))
751 ET.SubElement(cd, "target", dev=details_profile["cd"][0], bus=details_profile["cd"][1])
752 ET.SubElement(cd, "readonly")
753 if preset and spec["platform"] == "linux":
754 linux_seed(directory, spec["username"], preset["os"], details_profile.get("serial", "ttyS0"))
755 seed = ET.SubElement(devices, "disk", type="file", device="cdrom")
756 ET.SubElement(seed, "driver", name="qemu", type="raw")
757 ET.SubElement(seed, "source", file=str(directory / "seed.iso"))
758 ET.SubElement(seed, "target", dev=details_profile["seed"][0], bus=details_profile["seed"][1])
759 ET.SubElement(seed, "serial").text = "studio-cloud-init"
760 ET.SubElement(seed, "readonly")
761 if preset and spec["platform"] == "windows":
762 windows_seed(directory, spec["username"])
763 ET.SubElement(cd, "source", file=str(directory / "seed.iso"))
764 ET.SubElement(cd, "serial").text = "snowglobe-provision"
765 nic = ET.SubElement(devices, "interface", type="network")
766 ET.SubElement(nic, "source", network="default")
767 ET.SubElement(nic, "model", type=details_profile["network"])
768 ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
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)
773 ET.SubElement(devices, "input", type="tablet", bus="usb")
774 if keyboard := details_profile.get("keyboard"):
775 ET.SubElement(devices, "input", type="keyboard", bus=keyboard)
776 ET.SubElement(devices, "controller", type="usb", model=details_profile["usb"])
777 if details_profile.get("tpm"):
778 tpm = ET.SubElement(devices, "tpm", model="tpm-crb")
779 ET.SubElement(tpm, "backend", type="emulator", version="2.0")
780 if details_profile.get("balloon"):
781 ET.SubElement(devices, "memballoon", model=details_profile["balloon"])
782 ET.SubElement(devices, "console", type="pty")
783 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0"))
784 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name=GUEST_CHANNEL))
785 xml = directory / "domain.xml"
786 xml.write_bytes(ET.tostring(root))
787 signal.alarm(0)
788 virsh("define", str(xml))
789 if spec["autostart"]:
790 virsh("autostart", name)
791 if spec["start"]:
792 ensure_network()
793 virsh("start", name)
794 except Exception:
795 signal.alarm(0)
796 if name not in virsh("list", "--all", "--name").splitlines():
797 for file in directory.iterdir():
798 file.unlink()
799 directory.rmdir()
800 raise
801 finally:
802 signal.alarm(0)
803 signal.signal(signal.SIGALRM, previous)
804
805
806def main():
807 action = sys.argv[1]
808 payload = json.loads(sys.argv[2]) if len(sys.argv) > 2 else None
809 validate(action, payload)
810 if action == "node":
811 return node()
812 if action == "domains":
813 return domains()
814 if action == "stats":
815 result = {}
816 for name in virsh("list", "--name").splitlines():
817 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
818 if not pid_file.exists():
819 continue
820 pid = pid_file.read_text().strip()
821 fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
822 resident = int(Path(f"/proc/{pid}/statm").read_text().split()[1]) * os.sysconf("SC_PAGE_SIZE")
823 result[name] = {"cpu": (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK"), "memory": resident,
824 "vcpus": int(ET.fromstring(virsh("dumpxml", name)).findtext("vcpu", "1"))}
825 return result
826 if action == "library":
827 return library()
828 if action == "console":
829 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}
837 if action == "serial":
838 if virsh("domstate", payload["name"]).strip() not in {"running", "blocked"}:
839 raise ValueError("Start this VM before opening its console.")
840 root = ET.fromstring(virsh("dumpxml", payload["name"]))
841 console = root.find("./devices/console[@type='pty']")
842 source = console.find("source") if console is not None else None
843 target = console.find("target") if console is not None else None
844 path = source.get("path", "") if source is not None else ""
845 if target is None or target.get("type") != "serial" or not re.fullmatch(r"/dev/pts/[0-9]+", path):
846 raise ValueError("This VM has no serial console. Add one in its hardware settings.")
847 return {"path": path}
848 if action == "owner":
849 return ET.fromstring(virsh("dumpxml", payload["name"])).findtext(f"metadata/{NS}vm/{NS}owner")
850 if action == "access":
851 try:
852 with os.fdopen(open_regular(DISKS / payload["name"] / "access.json")) as incoming:
853 return json.load(incoming)
854 except FileNotFoundError:
855 return None
856 if action == "media":
857 change_media(payload)
858 elif action == "preset":
859 save_preset(payload)
860 elif action == "create":
861 create(payload)
862 elif action == "act":
863 name = payload["name"]
864 ensure_name(name)
865 if payload["action"] == "start":
866 ensure_network()
867 virsh({"start": "start", "shutdown": "shutdown", "reboot": "reboot", "destroy": "destroy", "resume": "resume"}[payload["action"]], name)
868 elif action == "update":
869 name = payload["name"]
870 ensure_name(name)
871 if "autostart" in payload:
872 virsh("autostart", *([] if payload["autostart"] else ["--disable"]), name)
873 if "description" in payload:
874 flags = ["--config"]
875 if virsh("domstate", name).strip() != "shut off":
876 flags.append("--live")
877 virsh("desc", name, *flags, "--", payload["description"])
878 elif action == "remove":
879 name = payload["name"]
880 ensure_name(name)
881 if name not in virsh("list", "--all", "--name").splitlines():
882 raise ValueError("VM does not exist")
883 if virsh("domstate", name).strip() != "shut off":
884 virsh("destroy", name)
885 root = ET.fromstring(virsh("dumpxml", name))
886 virsh("undefine", name, *(["--nvram"] if root.find("./os/nvram") is not None else []), *(["--tpm"] if root.find("./devices/tpm") is not None else []))
887 directory = DISKS / name
888 if payload["disks"] and directory.is_dir():
889 for file in directory.iterdir():
890 file.unlink()
891 directory.rmdir()
892 else:
893 raise ValueError("unsupported VM action")
894 return None
895
896
897if __name__ == "__main__":
898 try:
899 print(json.dumps(main()))
900 except ValueError as failure:
901 print(str(failure), file=sys.stderr)
902 sys.exit(2)
903 except (OSError, subprocess.CalledProcessError) as failure:
904 print(str(failure), file=sys.stderr)
905 sys.exit(1)