1#!/usr/bin/env python3
2
3import argparse
4import base64
5import fcntl
6import hashlib
7import json
8import os
9from pathlib import Path
10import re
11import shlex
12import shutil
13import socket
14import subprocess
15import tempfile
16import time
17import urllib.parse
18import urllib.request
19import uuid
20
21from lab_network import ensure_hub
22
23
24from env import ROOT, setting
25
26
27VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser()
28LINUX_HOME = VM_HOME / "linux"
29IMAGES = LINUX_HOME / "images"
30INSTANCES = LINUX_HOME / "instances"
31RUN = LINUX_HOME / "run"
32BASE = IMAGES / "debian-13-genericcloud-arm64.qcow2"
33BASE_MANIFEST = IMAGES / "debian-13-genericcloud-arm64.json"
34IMAGE_URL = os.environ.get(
35 "LINUX_VM_IMAGE_URL",
36 "https://cloud.debian.org/images/cloud/trixie/latest/"
37 "debian-13-genericcloud-arm64.qcow2",
38)
39FIRMWARE = Path(os.environ.get(
40 "LINUX_VM_FIRMWARE", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd"
41))
42LAB_ADDRESS = "192.168.77.1"
43NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?")
44
45
46def require_vm_home():
47 if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes":
48 volume = Path("/Volumes") / VM_HOME.parts[2]
49 if not os.path.ismount(volume):
50 raise SystemExit("VM volume is not mounted: %s" % volume)
51
52
53def executable(name):
54 path = shutil.which(name) or "/opt/homebrew/bin/" + name
55 if not Path(path).is_file():
56 raise SystemExit("Missing executable: %s" % name)
57 return path
58
59
60def atomic_json(path, value):
61 path.parent.mkdir(parents=True, exist_ok=True)
62 temporary = path.with_suffix(path.suffix + ".tmp")
63 temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
64 temporary.replace(path)
65
66
67def runtime(name):
68 root = RUN / name
69 return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log"
70
71
72def running(name):
73 try:
74 os.kill(int(runtime(name)[2].read_text()), 0)
75 return True
76 except (FileNotFoundError, ProcessLookupError, ValueError):
77 return False
78
79
80def instance_path(name):
81 return INSTANCES / (name + ".json")
82
83
84def validate_name(name):
85 if not NAME.fullmatch(name):
86 raise SystemExit("VM name must be 1-24 lowercase letters, digits, or hyphens")
87
88
89def load_instance(name):
90 validate_name(name)
91 try:
92 return json.loads(instance_path(name).read_text())
93 except FileNotFoundError:
94 raise SystemExit("Linux VM does not exist: %s" % name)
95 except (OSError, ValueError) as e:
96 raise SystemExit("Cannot read Linux VM %s: %s" % (name, e))
97
98
99def configs():
100 if not INSTANCES.exists():
101 return []
102 return [(path.stem, json.loads(path.read_text()))
103 for path in sorted(INSTANCES.glob("*.json"))]
104
105
106def available_port(field, start, end, requested=None):
107 used = {config[field] for _name, config in configs()}
108 candidates = [requested] if requested else range(start, end + 1)
109 for port in candidates:
110 if port is None or port < 1024 or port > 65535 or port in used:
111 continue
112 with socket.socket() as probe:
113 try:
114 probe.bind(("127.0.0.1", port))
115 except OSError:
116 continue
117 return port
118 raise SystemExit("No unused %s is available" % field.replace("_", " "))
119
120
121def mac(prefix, name):
122 tail = uuid.uuid5(uuid.NAMESPACE_URL, prefix + name).bytes[-3:]
123 kind = 4 if prefix == "lab:" else 3
124 return "52:54:%02x:%02x:%02x:%02x" % (kind, *tail)
125
126
127def key_path(name):
128 return INSTANCES / (name + ".key")
129
130
131def seed_path(name):
132 return INSTANCES / (name + "-seed.iso")
133
134
135def overlay_path(name):
136 return INSTANCES / (name + ".qcow2")
137
138
139DESKTOP_PACKAGES = [
140 "xvfb", "openbox", "xdotool", "xclip", "maim", "x11-utils", "dbus-user-session",
141 "at-spi2-core", "python3-pyatspi", "libatk-adaptor", "mesa-vulkan-drivers",
142 "libgl1-mesa-dri", "libxkbcommon-x11-0", "libxcursor1", "libxi6", "libxrandr2",
143 "fonts-liberation", "fonts-crosextra-carlito", "build-essential", "pkg-config",
144 "rsync", "xterm", "zenity",
145]
146# 1280 wide keeps screenshots under the model's downscale threshold, so image
147# pixels stay click coordinates.
148DESKTOP_UNITS = {
149 "agent-display.service": """[Unit]
150Description=Agent X display
151
152[Service]
153ExecStart=/usr/bin/Xvfb :0 -screen 0 1280x800x24 -nolisten tcp
154
155[Install]
156WantedBy=default.target
157""",
158 "agent-wm.service": """[Unit]
159Description=Agent window manager
160Requires=agent-display.service
161After=agent-display.service
162
163[Service]
164ExecStartPre=/bin/sh -c 'until xdpyinfo >/dev/null 2>&1; do sleep 0.1; done'
165ExecStartPre=/usr/bin/busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true
166ExecStart=/usr/bin/openbox
167
168[Install]
169WantedBy=default.target
170""",
171}
172
173
174def make_seed(path, hostname, public_key, wan_mac, lab_mac, desktop=False):
175 samba = """[global]
176workgroup = WORKGROUP
177server role = standalone server
178map to guest = Bad User
179server min protocol = SMB2_02
180interfaces = lo wan0 lab0
181bind interfaces only = yes
182
183[agent]
184path = /srv/agent
185read only = no
186guest ok = yes
187force user = agent
188create mask = 0666
189directory mask = 0777
190"""
191 dnsmasq = """port=0
192interface=lab0
193bind-interfaces
194dhcp-range=192.168.77.10,192.168.77.250,255.255.255.0,12h
195dhcp-option=3
196dhcp-option=6
197"""
198 user_data = """#cloud-config
199hostname: {hostname}
200manage_etc_hosts: true
201users:
202 - name: agent
203 gecos: OneNote test agent
204 groups: [sudo]
205 shell: /bin/bash
206 sudo: ALL=(ALL) NOPASSWD:ALL
207 lock_passwd: true
208 ssh_authorized_keys:
209 - {public_key}
210ssh_pwauth: false
211disable_root: true
212package_update: true
213packages: [{packages}]
214write_files:
215 - path: /etc/samba/smb.conf
216 permissions: '0644'
217 encoding: b64
218 content: {samba}
219 - path: /etc/dnsmasq.d/onenote-lab.conf
220 permissions: '0644'
221 encoding: b64
222 content: {dnsmasq}
223{desktop_files}runcmd:
224 - [mkdir, -p, /srv/agent]
225 - [chown, agent:agent, /srv/agent]
226 - [systemctl, enable, --now, dnsmasq]
227 - [systemctl, enable, --now, smbd]
228{desktop_commands}""".format(
229 hostname=hostname,
230 public_key=public_key,
231 packages=", ".join(["samba", "dnsmasq", "cifs-utils", "smbclient", "fio"] +
232 (DESKTOP_PACKAGES if desktop else [])),
233 samba=base64.b64encode(samba.encode()).decode(),
234 dnsmasq=base64.b64encode(dnsmasq.encode()).decode(),
235 # /etc/environment reaches SSH sessions; environment.d reaches user units.
236 desktop_files="".join(
237 " - path: %s\n append: true\n encoding: b64\n content: %s\n"
238 % (path, base64.b64encode(content.encode()).decode())
239 for path, content in [("/etc/systemd/user/" + unit, text)
240 for unit, text in DESKTOP_UNITS.items()] +
241 [("/etc/environment", "DISPLAY=:0\n"),
242 ("/etc/environment.d/display.conf", "DISPLAY=:0\n")]
243 ) if desktop else "",
244 # The wait loop's SSH probes start the user manager before these units
245 # and dbus-user-session exist, so it must restart to pick them up.
246 desktop_commands=(
247 " - [systemctl, --global, enable, %s]\n"
248 " - [loginctl, enable-linger, agent]\n"
249 " - [sh, -c, 'systemctl restart user@$(id -u agent).service']\n"
250 % ", ".join(DESKTOP_UNITS)
251 ) if desktop else "",
252 )
253 network = """version: 2
254ethernets:
255 wan0:
256 match:
257 macaddress: "{wan_mac}"
258 set-name: wan0
259 dhcp4: true
260 lab0:
261 match:
262 macaddress: "{lab_mac}"
263 set-name: lab0
264 addresses: [{lab_address}/24]
265""".format(wan_mac=wan_mac, lab_mac=lab_mac, lab_address=LAB_ADDRESS)
266 path.parent.mkdir(parents=True, exist_ok=True)
267 with tempfile.TemporaryDirectory(prefix="one-linux-seed-") as temporary:
268 root = Path(temporary)
269 (root / "meta-data").write_text(
270 "instance-id: %s\nlocal-hostname: %s\n" % (hostname, hostname)
271 )
272 (root / "user-data").write_text(user_data)
273 (root / "network-config").write_text(network)
274 subprocess.run([
275 "hdiutil", "makehybrid", "-quiet", "-iso", "-joliet",
276 "-default-volume-name", "cidata", "-o", str(path), str(root),
277 ], check=True)
278
279
280def fetch_base():
281 require_vm_home()
282 if BASE.exists() and BASE_MANIFEST.exists():
283 subprocess.run([executable("qemu-img"), "check", str(BASE)], check=True)
284 print(BASE_MANIFEST)
285 return
286 headers = {}
287 if os.environ.get("ONE_VM_AUTHORIZATION"):
288 headers["Authorization"] = os.environ["ONE_VM_AUTHORIZATION"]
289 expected = os.environ.get("LINUX_VM_SHA512", "").lower()
290 if not expected:
291 sums_url = urllib.parse.urljoin(IMAGE_URL, "SHA512SUMS")
292 with urllib.request.urlopen(urllib.request.Request(sums_url, headers=headers)) as response:
293 sums = response.read().decode()
294 filename = urllib.parse.urlparse(IMAGE_URL).path.rsplit("/", 1)[-1]
295 match = re.search(r"^([0-9a-f]{128})\s+\*?%s$" % re.escape(filename),
296 sums, re.MULTILINE)
297 if not match:
298 raise SystemExit("Image is absent from %s" % sums_url)
299 expected = match.group(1)
300 if not re.fullmatch(r"[0-9a-f]{128}", expected):
301 raise SystemExit("LINUX_VM_SHA512 must contain 128 hexadecimal characters")
302 IMAGES.mkdir(parents=True, exist_ok=True)
303 temporary = BASE.with_suffix(".download.qcow2")
304 digest = hashlib.sha512()
305 try:
306 with urllib.request.urlopen(urllib.request.Request(IMAGE_URL, headers=headers)) as response:
307 with temporary.open("wb") as output:
308 while chunk := response.read(8 * 1024 * 1024):
309 output.write(chunk)
310 digest.update(chunk)
311 if digest.hexdigest() != expected:
312 raise SystemExit("Downloaded Linux image failed SHA-512 verification")
313 subprocess.run([executable("qemu-img"), "check", str(temporary)], check=True)
314 temporary.replace(BASE)
315 BASE.chmod(0o444)
316 atomic_json(BASE_MANIFEST, {"file": BASE.name, "sha512": expected,
317 "url": IMAGE_URL})
318 except Exception:
319 temporary.unlink(missing_ok=True)
320 raise
321 print(BASE_MANIFEST)
322
323
324def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16,
325 ssh_port=None, samba_port=None, desktop=False):
326 require_vm_home()
327 validate_name(name)
328 if not BASE.is_file() or not BASE_MANIFEST.is_file():
329 raise SystemExit("No Linux base image. Run: ./linux_vm.py fetch")
330 if not 1 <= cpus <= 16:
331 raise SystemExit("CPU count must be between 1 and 16")
332 if not 512 <= memory_mb <= 65536:
333 raise SystemExit("Memory must be between 512 and 65536 MiB")
334 if not 8 <= disk_gb <= 1024:
335 raise SystemExit("Disk size must be between 8 and 1024 GiB")
336 LINUX_HOME.mkdir(parents=True, exist_ok=True)
337 with (LINUX_HOME / ".lock").open("a") as lock:
338 fcntl.flock(lock, fcntl.LOCK_EX)
339 if instance_path(name).exists():
340 raise SystemExit("Linux VM already exists: %s" % name)
341 ssh_port = available_port("ssh_port", 22000, 22099, ssh_port)
342 samba_port = available_port("samba_port", 14450, 14549, samba_port)
343 hostname = "one-" + name
344 private = key_path(name)
345 public = private.with_suffix(".key.pub")
346 overlay = overlay_path(name)
347 seed = seed_path(name)
348 INSTANCES.mkdir(parents=True, exist_ok=True)
349 try:
350 subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "",
351 "-C", hostname, "-f", str(private)], check=True)
352 private.chmod(0o600)
353 subprocess.run([
354 executable("qemu-img"), "create", "-f", "qcow2", "-F", "qcow2",
355 "-b", str(BASE), str(overlay),
356 ], check=True)
357 subprocess.run([executable("qemu-img"), "resize", str(overlay),
358 "%dG" % disk_gb], check=True)
359 wan = mac("wan:", name)
360 lab = mac("lab:", name)
361 make_seed(seed, hostname, public.read_text().strip(), wan, lab, desktop)
362 seed.chmod(0o600)
363 config = {"cpus": cpus, "desktop": desktop, "disk_gb": disk_gb,
364 "hostname": hostname,
365 "lab_address": LAB_ADDRESS, "lab_mac": lab,
366 "memory_mb": memory_mb, "samba_port": samba_port,
367 "ssh_port": ssh_port, "wan_mac": wan}
368 atomic_json(instance_path(name), config)
369 instance_path(name).chmod(0o600)
370 except Exception:
371 for path in (private, public, overlay, seed, instance_path(name)):
372 path.unlink(missing_ok=True)
373 raise
374 print(json.dumps(dict(config, name=name), sort_keys=True))
375
376
377def launch(name):
378 require_vm_home()
379 config = load_instance(name)
380 if running(name):
381 raise SystemExit("Linux VM is already running: %s" % name)
382 active = [other for other, _config in configs() if other != name and running(other)]
383 if active:
384 raise SystemExit("Only one Samba VM may use %s: %s" %
385 (LAB_ADDRESS, ", ".join(active)))
386 if not FIRMWARE.is_file():
387 raise SystemExit("AArch64 UEFI firmware not found: %s" % FIRMWARE)
388 root, qmp_socket, pid, log_path = runtime(name)
389 root.mkdir(parents=True, exist_ok=True)
390 qmp_socket.unlink(missing_ok=True)
391 lab_socket = ensure_hub(VM_HOME)
392 command = [
393 executable("qemu-system-aarch64"),
394 "-name", "OneNote Linux Samba " + name,
395 "-machine", "virt,accel=hvf", "-cpu", "host",
396 "-smp", str(config["cpus"]), "-m", str(config["memory_mb"]),
397 "-bios", str(FIRMWARE),
398 "-drive", "if=none,file=%s,format=qcow2,id=root" % overlay_path(name),
399 "-device", "virtio-blk-pci,drive=root",
400 "-device", "virtio-scsi-pci,id=scsi",
401 "-drive", "if=none,file=%s,format=raw,media=cdrom,readonly=on,id=seed" % seed_path(name),
402 "-device", "scsi-cd,drive=seed",
403 "-netdev", ("user,id=wan,hostfwd=tcp:127.0.0.1:%d-:22,"
404 "hostfwd=tcp:127.0.0.1:%d-:445" %
405 (config["ssh_port"], config["samba_port"])),
406 "-device", "virtio-net-pci,netdev=wan,mac=%s" % config["wan_mac"],
407 "-netdev", "vde,id=lab,sock=%s" % lab_socket,
408 "-device", "virtio-net-pci,netdev=lab,mac=%s" % config["lab_mac"],
409 "-uuid", str(uuid.uuid5(uuid.NAMESPACE_URL, "onenote-linux:" + name)),
410 "-display", "none", "-serial", "stdio", "-monitor", "none",
411 "-qmp", "unix:%s,server=on,wait=off" % qmp_socket,
412 "-pidfile", str(pid),
413 ]
414 with log_path.open("ab") as log:
415 subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
416 start_new_session=True)
417 for _ in range(50):
418 if running(name):
419 print("Linux VM opened: %s" % name)
420 return
421 time.sleep(0.1)
422 raise SystemExit("Linux VM did not open. Check: %s" % log_path)
423
424
425def ssh_argv(name, command=None):
426 config = load_instance(name)
427 known = INSTANCES / (name + ".known_hosts")
428 argv = ["ssh", "-p", str(config["ssh_port"]), "-i", str(key_path(name)),
429 "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
430 "-o", "StrictHostKeyChecking=accept-new",
431 "-o", 'UserKnownHostsFile="%s"' % known,
432 "-o", "ConnectTimeout=5", "agent@127.0.0.1"]
433 if command is not None:
434 argv.append(command)
435 return argv
436
437
438def run_ssh(name, command, timeout=60):
439 return subprocess.run(ssh_argv(name, command), capture_output=True, text=True,
440 timeout=timeout)
441
442
443def wait_instance(name, timeout):
444 deadline = time.monotonic() + timeout
445 while time.monotonic() < deadline:
446 try:
447 if run_ssh(name, "true", timeout=8).returncode == 0:
448 break
449 except subprocess.TimeoutExpired:
450 pass
451 time.sleep(1)
452 else:
453 raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name))
454 remaining = max(1, int(deadline - time.monotonic()))
455 validation = ("sudo cloud-init status --wait && "
456 "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && "
457 "systemctl is-active --quiet smbd dnsmasq")
458 if load_instance(name).get("desktop"):
459 validation += (" && timeout 60 sh -c 'until systemctl --user is-active --quiet "
460 "agent-wm; do sleep 0.2; done'")
461 try:
462 result = run_ssh(name, validation, timeout=remaining)
463 except subprocess.TimeoutExpired:
464 raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name))
465 if result.returncode:
466 raise SystemExit((result.stdout + result.stderr).strip() or
467 "Linux bootstrap validation failed: %s" % name)
468 config = load_instance(name)
469 print("Ready: %s ssh=127.0.0.1:%d smb=127.0.0.1:%d windows=//%s/agent" %
470 (name, config["ssh_port"], config["samba_port"], LAB_ADDRESS))
471
472
473def qmp(name, command):
474 with socket.socket(socket.AF_UNIX) as client:
475 client.settimeout(5)
476 client.connect(str(runtime(name)[1]))
477 stream = client.makefile("rwb", buffering=0)
478 stream.readline()
479 stream.write(b'{"execute":"qmp_capabilities"}\n')
480 while "return" not in json.loads(stream.readline()):
481 pass
482 stream.write((json.dumps({"execute": command}) + "\n").encode())
483 while True:
484 response = json.loads(stream.readline())
485 if "return" in response:
486 return response["return"]
487 if "error" in response:
488 raise SystemExit(response["error"]["desc"])
489
490
491def shutdown(name, timeout):
492 if not running(name):
493 raise SystemExit("Linux VM is not running: %s" % name)
494 try:
495 run_ssh(name, "sudo poweroff", timeout=10)
496 except (subprocess.TimeoutExpired, OSError):
497 qmp(name, "system_powerdown")
498 deadline = time.monotonic() + timeout
499 while time.monotonic() < deadline:
500 if not running(name):
501 print("Linux VM stopped: %s" % name)
502 return
503 time.sleep(1)
504 raise SystemExit("Linux VM did not stop within %d seconds: %s" % (timeout, name))
505
506
507def delete_instance(name):
508 require_vm_home()
509 load_instance(name)
510 if running(name):
511 raise SystemExit("Shut down Linux before deleting: %s" % name)
512 for path in (overlay_path(name), seed_path(name), key_path(name),
513 key_path(name).with_suffix(".key.pub"),
514 INSTANCES / (name + ".known_hosts"), instance_path(name)):
515 path.unlink(missing_ok=True)
516 shutil.rmtree(runtime(name)[0], ignore_errors=True)
517 print("Deleted Linux VM: %s" % name)
518
519
520def list_instances(name=None):
521 if name:
522 load_instance(name)
523 print("running" if running(name) else "stopped")
524 return
525 rows = configs()
526 if not rows:
527 print("No Linux VMs.")
528 return
529 for vm_name, config in rows:
530 state = "running" if running(vm_name) else "stopped"
531 print("%-24s %-24s %-7s ssh:%d smb:%d" %
532 (vm_name, config["hostname"], state, config["ssh_port"],
533 config["samba_port"]))
534
535
536def main():
537 parser = argparse.ArgumentParser(description="Run SSH-only OneNote Linux Samba VMs")
538 commands = parser.add_subparsers(dest="command", required=True)
539 commands.add_parser("fetch")
540 status = commands.add_parser("status")
541 status.add_argument("name", nargs="?")
542 up = commands.add_parser("up")
543 up.add_argument("name")
544 up.add_argument("--cpus", type=int, default=2)
545 up.add_argument("--memory", type=int, default=2048, dest="memory_mb")
546 up.add_argument("--disk", type=int, default=16, dest="disk_gb")
547 up.add_argument("--ssh-port", type=int)
548 up.add_argument("--samba-port", type=int)
549 up.add_argument("--desktop", action="store_true")
550 up.add_argument("--wait", action="store_true")
551 up.add_argument("--timeout", type=int, default=600)
552 ssh = commands.add_parser("ssh")
553 ssh.add_argument("name")
554 ssh.add_argument("remote_command", nargs=argparse.REMAINDER)
555 down = commands.add_parser("down")
556 down.add_argument("name")
557 down.add_argument("--timeout", type=int, default=60)
558 down.add_argument("--preserve-machine", action="store_true")
559 args = parser.parse_args()
560 if args.command == "fetch":
561 fetch_base()
562 elif args.command == "up":
563 if not instance_path(args.name).exists():
564 create_instance(args.name, args.cpus, args.memory_mb, args.disk_gb,
565 args.ssh_port, args.samba_port, args.desktop)
566 if running(args.name):
567 print("Linux VM already running: %s" % args.name)
568 else:
569 launch(args.name)
570 if args.wait:
571 wait_instance(args.name, args.timeout)
572 elif args.command == "ssh":
573 command = args.remote_command
574 if command[:1] == ["--"]:
575 command = command[1:]
576 remote = command[0] if len(command) == 1 else shlex.join(command)
577 os.execvp("ssh", ssh_argv(args.name, remote if command else None))
578 elif args.command == "down":
579 if not instance_path(args.name).exists():
580 print("Linux VM absent: %s" % args.name)
581 else:
582 load_instance(args.name)
583 if running(args.name):
584 shutdown(args.name, args.timeout)
585 if args.preserve_machine:
586 print("Preserved Linux VM: %s" % args.name)
587 else:
588 delete_instance(args.name)
589 else:
590 if args.name and not instance_path(args.name).exists():
591 print("absent")
592 else:
593 list_instances(args.name)
594
595
596if __name__ == "__main__":
597 main()