450 lines
14 KiB
Python
Executable File
450 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import atexit
|
|
import binascii
|
|
import http.server
|
|
import os
|
|
import platform
|
|
import select
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import socketserver
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
is_verbose = False
|
|
def print_verbose(s):
|
|
if is_verbose:
|
|
print(s)
|
|
|
|
def print_error(s):
|
|
print(s, file=sys.stderr)
|
|
|
|
def exit_error(s):
|
|
print_error(s)
|
|
sys.exit(1)
|
|
|
|
def bool_arg(val):
|
|
return "on" if val else "off"
|
|
|
|
def find_qemu(arch):
|
|
binary_names = [ f"qemu-system-{arch}" ]
|
|
if arch == platform.machine():
|
|
binary_names.append("qemu-kvm")
|
|
|
|
for binary_name in binary_names:
|
|
if "QEMU_BUILD_DIR" in os.environ:
|
|
p = os.path.join(os.environ["QEMU_BUILD_DIR"], binary_name)
|
|
if os.path.isfile(p):
|
|
return p
|
|
else:
|
|
exit_error(f"Can't find {binary_name}")
|
|
|
|
qemu_bin_dirs = ["/usr/bin", "/usr/libexec"]
|
|
if "PATH" in os.environ:
|
|
qemu_bin_dirs += os.environ["PATH"].split(":")
|
|
|
|
for d in qemu_bin_dirs:
|
|
p = os.path.join(d, binary_name)
|
|
if os.path.isfile(p):
|
|
return p
|
|
|
|
exit_error(f"Can't find {binary_name}")
|
|
|
|
def qemu_available_accels(qemu):
|
|
cmd = qemu + ' -accel help'
|
|
info = subprocess.check_output(cmd.split(" ")).decode('utf-8')
|
|
accel_list = []
|
|
for accel in ('kvm', 'xen', 'hvf', 'hax', 'tcg'):
|
|
if info.find(accel) > 0:
|
|
accel_list.append(accel)
|
|
return accel_list
|
|
|
|
def random_id():
|
|
return binascii.b2a_hex(os.urandom(8)).decode('utf8')
|
|
|
|
def machine_id():
|
|
try:
|
|
with open("/etc/machine-id", "r") as f:
|
|
mid = f.read().strip()
|
|
except FileNotFoundError:
|
|
if sys.platform == "darwin":
|
|
# for macOS
|
|
import plistlib
|
|
cmd = "ioreg -rd1 -c IOPlatformExpertDevice -a"
|
|
plist_data = subprocess.check_output(cmd.split(" "))
|
|
mid = plistlib.loads(plist_data)[0]["IOPlatformUUID"].replace("-","")
|
|
else:
|
|
# fallback for the other distros
|
|
hostname = socket.gethostname()
|
|
mid = ''.join(hex(ord(x))[2:] for x in (hostname*16)[:16])
|
|
|
|
return mid
|
|
|
|
def generate_mac_address():
|
|
# create a new mac address based on our machine id
|
|
data = machine_id()
|
|
|
|
maclst = ["FE"] + [data[x:x+2] for x in range(-12, -2, 2)]
|
|
return ":".join(maclst)
|
|
|
|
def run_http_server(path):
|
|
writer, reader = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
child_pid = os.fork()
|
|
if child_pid == 0:
|
|
reader.close()
|
|
|
|
# Child
|
|
os.chdir(path)
|
|
|
|
class HTTPHandler(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
pass # Silence logs
|
|
|
|
httpd = socketserver.TCPServer(("127.0.0.1", 0), HTTPHandler)
|
|
writer.send(str(httpd.server_address[1]).encode("utf8"))
|
|
writer.close()
|
|
|
|
httpd.serve_forever()
|
|
sys.exit(0)
|
|
|
|
# Parent
|
|
writer.close()
|
|
atexit.register(os.kill, child_pid,signal.SIGTERM)
|
|
|
|
http_port = int(reader.recv(128).decode("utf8"))
|
|
reader.close()
|
|
|
|
return http_port
|
|
|
|
def find_ovmf(args):
|
|
dirs = [
|
|
"~/.local/share/ovmf",
|
|
"/usr/share/OVMF",
|
|
"/usr/share/edk2/ovmf/"
|
|
]
|
|
if args.ovmf_dir:
|
|
dirs.append(args.ovmf_dir)
|
|
|
|
for d in dirs:
|
|
path = os.path.expanduser(d)
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
raise RuntimeError("Could not find OMVF")
|
|
|
|
# location can differ depending on how qemu is installed
|
|
def find_edk2():
|
|
dirs = [
|
|
"/usr/local/share/qemu",
|
|
"/opt/homebrew/share/qemu"
|
|
]
|
|
|
|
for d in dirs:
|
|
path = os.path.expanduser(d)
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
raise RuntimeError("Could not find edk2 directory")
|
|
|
|
def qemu_run_command(qmp_socket_path, command):
|
|
sock2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock2.connect(qmp_socket_path)
|
|
r = sock2.recv(1024)
|
|
sock2.send('{"execute":"qmp_capabilities"}\n'.encode("utf8"))
|
|
r = sock2.recv(1024)
|
|
sock2.send(f'{command}\n'.encode("utf8"))
|
|
r = sock2.recv(1024)
|
|
sock2.close()
|
|
|
|
def virtio_serial_connect(virtio_socket_path):
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
while True:
|
|
time.sleep(0.1)
|
|
try:
|
|
sock.connect(virtio_socket_path)
|
|
return sock
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def available_tcp_port(port_range_from = 1024):
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
port = port_range_from
|
|
port_range_to = port_range_from + 32 # limit for retry
|
|
while port < port_range_to:
|
|
try:
|
|
s.bind(('', port))
|
|
except OSError:
|
|
port += 1
|
|
continue
|
|
break
|
|
s.close()
|
|
return port
|
|
|
|
class WatchdogCommand:
|
|
START = 1
|
|
STOP = 2
|
|
|
|
def __init__(self, op, arg = None):
|
|
self.op = op
|
|
self.arg = arg
|
|
|
|
def parse_watchdog_commands(sock):
|
|
commands = []
|
|
data = sock.recv(16).decode("utf8")
|
|
for l in data.splitlines():
|
|
if l.startswith("START"):
|
|
try:
|
|
arg = int(l[5:])
|
|
except ValueError:
|
|
arg = 30 # Default if not specified
|
|
commands.append( WatchdogCommand(WatchdogCommand.START, arg) )
|
|
elif l.startswith("STOP"):
|
|
commands.append( WatchdogCommand(WatchdogCommand.STOP) )
|
|
else:
|
|
print_verbose(f"Unsupported watchdog command {l}")
|
|
return commands
|
|
|
|
def run_watchdog(watch_socket_path, qmp_socket_path):
|
|
sock = virtio_serial_connect(watch_socket_path)
|
|
|
|
p = select.poll()
|
|
p.register(sock, select.POLLIN)
|
|
|
|
watchdog_timeout = None
|
|
watchdog_delay = 30
|
|
|
|
while True:
|
|
timeout = None
|
|
if watchdog_timeout != None:
|
|
timeout = max(watchdog_timeout - time.time(), 0) * 1000
|
|
|
|
poll_res = p.poll(timeout)
|
|
|
|
if len(poll_res) > 0:
|
|
v = poll_res[0][1]
|
|
if v & select.POLLHUP:
|
|
sys.exit(0)
|
|
commands = parse_watchdog_commands(sock)
|
|
for cmd in commands:
|
|
if cmd.op == WatchdogCommand.START:
|
|
print_verbose(f"Starting watchdog for {cmd.arg} sec")
|
|
watchdog_timeout = time.time() + cmd.arg
|
|
if cmd.op == WatchdogCommand.STOP:
|
|
print_verbose(f"Stopped watchdog")
|
|
watchdog_timeout = None
|
|
|
|
if watchdog_timeout != None and time.time() >= watchdog_timeout:
|
|
print_verbose(f"Triggering watchdog")
|
|
qemu_run_command(qmp_socket_path, '{"execute": "system_reset"}')
|
|
|
|
# Queue a new timeout in case the next boot fails, until disabled
|
|
watchdog_timeout = time.time() + watchdog_delay
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Boot virtual machine images")
|
|
parser.add_argument("--verbose", default=False, action="store_true")
|
|
parser.add_argument("--arch", default=platform.machine(), action="store",
|
|
help=f"Arch to run for (default {platform.machine()})")
|
|
parser.add_argument("--publish-dir", action="store",
|
|
help=f"Publish the specified directory over http in the vm")
|
|
parser.add_argument("--memory", default="2G",
|
|
help=f"Memory size (default 2G)")
|
|
parser.add_argument("--nographics", default=False, action="store_true",
|
|
help=f"Run without graphics")
|
|
parser.add_argument("--watchdog", default=False, action="store_true",
|
|
help=f"Enable watchdog")
|
|
parser.add_argument("--tpm2", default=False, action="store_true",
|
|
help=f"Enable TPM2")
|
|
parser.add_argument("--snapshot", default=False, action="store_true",
|
|
help=f"Work on a snapshot of the image")
|
|
parser.add_argument("--ovmf-dir", action="store",
|
|
help="Specify directory for OVMF files (Open Virtual Machine Firmware)")
|
|
parser.add_argument("--secureboot", dest="secureboot", action="store_true", default=False,
|
|
help="Enable SecureBoot")
|
|
parser.add_argument("--ssh-port", type=int, default=2222,
|
|
help="SSH port forwarding to SSH_PORT (default 2222)")
|
|
parser.add_argument("--cdrom", action="store",
|
|
help="Specify .iso to load")
|
|
parser.add_argument("image", type=str, help="The image to boot")
|
|
parser.add_argument('extra_args', nargs=argparse.REMAINDER, metavar="...", help="extra qemu arguments")
|
|
|
|
args = parser.parse_args(sys.argv[1:])
|
|
|
|
global is_verbose
|
|
is_verbose = args.verbose
|
|
|
|
# arm64 is an alias for aarch64 on macOS
|
|
if args.arch == "arm64":
|
|
args.arch = "aarch64"
|
|
|
|
qemu = find_qemu(args.arch)
|
|
accel_list = qemu_available_accels(qemu)
|
|
qemu_args = [qemu]
|
|
|
|
if args.arch == "x86_64":
|
|
machine = "q35"
|
|
default_cpu = "qemu64,+ssse3,+sse4.1,+sse4.2,+popcnt"
|
|
|
|
ovmf = find_ovmf(args)
|
|
if args.secureboot:
|
|
qemu_args += [
|
|
"-drive", f"file={ovmf}/OVMF_CODE.secboot.fd,if=pflash,format=raw,unit=0,readonly=on",
|
|
"-drive", f"file={ovmf}/OVMF_VARS.secboot.fd,if=pflash,format=raw,unit=1,snapshot=on,readonly=off",
|
|
]
|
|
else:
|
|
qemu_args += [
|
|
"-drive", f"file={ovmf}/OVMF_CODE.fd,if=pflash,format=raw,unit=0,readonly=on",
|
|
"-drive", f"file={ovmf}/OVMF_VARS.fd,if=pflash,format=raw,unit=1,snapshot=on,readonly=off",
|
|
]
|
|
elif args.arch == "aarch64":
|
|
machine = "virt"
|
|
default_cpu = "cortex-a57"
|
|
if sys.platform == "darwin":
|
|
edk2 = find_edk2()
|
|
qemu_args += [
|
|
"-device", "virtio-gpu-pci", # for display
|
|
"-display", "default,show-cursor=on", # for display
|
|
"-device", "qemu-xhci", # for keyboard
|
|
"-device", "usb-kbd", # for keyboard
|
|
"-device", "usb-tablet", # for mouse
|
|
"-smp", str(os.cpu_count()), # for max cores
|
|
"-drive", f"file={edk2}/edk2-aarch64-code.fd,if=pflash,format=raw,unit=0,readonly=on",
|
|
"-drive", f"file={edk2}/edk2-arm-vars.fd,if=pflash,format=raw,unit=1,snapshot=on,readonly=off"
|
|
]
|
|
else:
|
|
qemu_args += [
|
|
"-bios", "/usr/share/edk2/aarch64/QEMU_EFI.fd",
|
|
"-boot", "efi"
|
|
]
|
|
else:
|
|
exit_error(f"unsupported architecture {args.arch}")
|
|
|
|
accel_enabled = True
|
|
if 'kvm' in accel_list and os.path.exists("/dev/kvm"):
|
|
qemu_args += ['-enable-kvm']
|
|
elif 'hvf' in accel_list:
|
|
qemu_args += ['-accel', 'hvf']
|
|
else:
|
|
accel_enabled = False
|
|
print_verbose("Acceleration: off")
|
|
|
|
qemu_args += [
|
|
"-m", str(args.memory),
|
|
"-machine", machine,
|
|
"-cpu", "host" if accel_enabled else default_cpu
|
|
]
|
|
|
|
guestfwds=""
|
|
|
|
if args.publish_dir:
|
|
if shutil.which("netcat") is None:
|
|
print("Command `netcat` not found in path, ignoring publish-dir")
|
|
else:
|
|
httpd_port = run_http_server(args.publish_dir)
|
|
guestfwds = f"guestfwd=tcp:10.0.2.100:80-cmd:netcat 127.0.0.1 {httpd_port},"
|
|
print_verbose(f"publishing {args.publish_dir} on http://10.0.2.100/")
|
|
|
|
portfwd = {
|
|
available_tcp_port(args.ssh_port): 22
|
|
}
|
|
for local, remote in portfwd.items():
|
|
print_verbose(f"port: {local} → {remote}")
|
|
|
|
fwds = [f"hostfwd=tcp::{h}-:{g}" for h, g in portfwd.items()]
|
|
|
|
macstr = generate_mac_address()
|
|
print_verbose(f"MAC: {macstr}")
|
|
|
|
qemu_args += [
|
|
"-device", f"virtio-net-pci,netdev=n0,mac={macstr}",
|
|
"-netdev", "user,id=n0,net=10.0.2.0/24," + guestfwds + ",".join(fwds),
|
|
]
|
|
|
|
if args.nographics:
|
|
qemu_args += ["-nographic"]
|
|
|
|
runvm_id = random_id()
|
|
|
|
tmpdir = tempfile.TemporaryDirectory(prefix=f"runvm-{runvm_id}")
|
|
|
|
watchdog_pid = 0
|
|
if args.watchdog:
|
|
qmp_socket_path = os.path.join(tmpdir.name, "qmp-socket")
|
|
watch_socket_path = os.path.join(tmpdir.name, "watch-socket")
|
|
|
|
qemu_args += [
|
|
"-qmp", f"unix:{qmp_socket_path},server=on,wait=off",
|
|
"-device", "virtio-serial", "-chardev", f"socket,path={watch_socket_path},server=on,wait=off,id=watchdog",
|
|
"-device", "virtserialport,chardev=watchdog,name=watchdog.0"
|
|
]
|
|
|
|
watchdog_pid = os.fork()
|
|
if watchdog_pid == 0:
|
|
run_watchdog(watch_socket_path, qmp_socket_path)
|
|
sys.exit(0)
|
|
|
|
if args.tpm2:
|
|
if shutil.which("swtpm") is None:
|
|
exit_error("Command `swtpm` not found in path, this is needed for tpm2 support")
|
|
|
|
tpm2_socket = os.path.join(tmpdir.name, "tpm-socket")
|
|
|
|
if args.snapshot:
|
|
tpm2_path = os.path.join(tmpdir.name, "tpm2_state")
|
|
else:
|
|
tpm2_path = ".tpm2_state"
|
|
os.makedirs(tpm2_path, exist_ok=True)
|
|
|
|
swtpm_args = ["swtpm", "socket", "--tpm2", "--tpmstate", f"dir={tpm2_path}", "--ctrl", f"type=unixio,path={tpm2_socket}" ]
|
|
res = subprocess.Popen(swtpm_args)
|
|
|
|
qemu_args += [
|
|
"-chardev", f"socket,id=chrtpm,path={tpm2_socket}",
|
|
"-tpmdev", "emulator,id=tpm0,chardev=chrtpm",
|
|
"-device", "tpm-tis,tpmdev=tpm0"
|
|
]
|
|
|
|
print_verbose(f"Image: {args.image}")
|
|
|
|
if args.image.endswith(".raw"):
|
|
qemu_args += [
|
|
"-drive", f"file={args.image},index=0,media=disk,format=raw,if=virtio,snapshot={bool_arg(args.snapshot)}",
|
|
]
|
|
else: # assume qcow2
|
|
qemu_args += [
|
|
"-drive", f"file={args.image},index=0,media=disk,format=qcow2,if=virtio,snapshot={bool_arg(args.snapshot)}",
|
|
]
|
|
|
|
if args.cdrom:
|
|
qemu_args += [
|
|
"-cdrom", args.cdrom,
|
|
"-boot", "d"
|
|
]
|
|
|
|
qemu_args += args.extra_args
|
|
|
|
print_verbose(f"Running: {' '.join(qemu_args)}")
|
|
|
|
try:
|
|
res = subprocess.run(qemu_args, check=False)
|
|
except KeyboardInterrupt:
|
|
exit_error("Aborted")
|
|
|
|
if watchdog_pid:
|
|
os.kill(watchdog_pid, signal.SIGTERM)
|
|
|
|
tmpdir.cleanup()
|
|
|
|
return res.returncode
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|