Lachlan Cox

Codesigning My Way Into a Hypervisor

Being June it is Tech Conference Season here in 2026, if you missed it then let me give you the run down. Have you heard the good news about AI? and that’s pretty much it. Microsoft talked about stopping shoving copilot down people’s throats, then immediately changed their mind and did a 2.5 hour keynote talking about the new ways they want to shove copilot down peoples throat. Google AI IO happened (though admittedly at the end of May). But the weirdest was Apple’s WWDC event. Unlike every other year, they didn’t announce a whole lot outside of maybe fixing Siri ™️. But most the event outside of the AI talk was back pedalling Tahoe and doing a shit load of performance improvements to the system and swift.

A session that particularly caught my eye outside of the Apple container implementation was the WWDC26: Expand the capabilities of your Virtualization app session. I remember the massive improvements they did last year and have been meaning to play with the tech for a while but had no actual reason to use it. But then I had an idea, what if I deployed my tiny Bingo image from the Booting Go on Bare Metal post on the Apple Virtualization Framework? How much of a pain will this be? Will I run out of nurofen before I get this working?

For those who want this to be a nightmare of a project and watch me struggle, I will have to apologise as the framework itself was annoyingly simple. The two things that did fight me both turned out to live in the same place, the seam between macOS and the guest, and neither was where I expected. You can rage quit now and check out the result at Charlie.

Stubbing My Toe

So, I switched to Mac around 2 years ago. I was originally running Fedora as my main machine for multiple years, but everyone at work used Apple and it became progressively annoying doing work with them, often they would run things on Mac and shit would hit the fan and because I never used a Mac I had no idea how to fix things. So I eventually just decided to bite the bullet and switch getting a Mac Mini M2 a few weeks before the M3 came out. I, like an idiot, got the 256GB storage model. So between the applications I have, the size of the OS, compliance applications and the fact that Docker will just make storage disappear into thin air, I typically have around 2-15GB of free storage. Due to how Xcode works, I just don’t have enough storage to download and use Xcode.

So instead of using Xcode which handles absolutely everything for making Apple applications, I’ve had to install Swift directly from their website so I can use the toolchain and then manually deal with everything. I have never written Swift up until this point as well. So the game plan is, learn a new language and do a hypervisor at the same time. Things Rust people could only dream of.

Structuring the Project

The Apple documentation is very good. Though it didn’t really go through a structure for the project, so I did the following:

The fork of Bingo strips all the x86 and build tooling out of it, leaving it to be an ARM64 UEFI .img. Everything else was left the same. The goal was to deploy it with Swift using the Virtualization Framework and being able to do a curl request to the VM and getting my Hello, World! response.

To get a VM running I need 2 things, a VZVirtualMachineConfiguration which is the definition of the VM and a VZVirtualMachine which takes the config and allows you to run vm.start(). The other things I did in the project at this point was a console reader which would parse the virtio-console serial output and write it to the terminal.

Configuring the VM

The config was reasonably simple, first was setting how much resources you wanted to give it:

let config = VZVirtualMachineConfiguration()
config.cpuCount = 1                     // 1 vCPU
config.memorySize = 256 * 1024 * 1024   // 256 MB

The memory size for Bingo does matter, as the whole rootfs runs in RAM and doesn’t mount to a volume. The next thing to do was set how to actually boot the VM which in our case is a EFI.

let bootLoader = VZEFIBootLoader()
let variableStoreURL = try Self
    .supportDirectory() // Custom "Application Support" directory
    .appendingPathComponent("efi-vars.fd")

bootLoader.variableStore = try efiVariableStore(at: variableStoreURL)
config.bootLoader = bootLoader

There is a little bit to unpack here. So the supportDirectory() function that will create an Application Support directory for this application which is where we can store the guest’s runtime state in a EFI variable store. This ends up being ~/Library/Application Support/charlie/efi-vars.fd. The next thing to define was the rootfs disk and attach it as readonly.

let attachment = try VZDiskImageStorageDeviceAttachment(
    url: Bundle.module.url(forResource: "bingo.arm64", withExtension: "img")!,
    readOnly: true,
)

config.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]

The building of the Bingo image needs to be moved to the host/Sources/host/bingo.arm64.img, this way we can Bundle the resource into the swift build. The just build target deals with this. I also had to update the Package.swift to state that this resource exists. The next thing is setting up the network and entropy for DHCP.

let network = VZVirtioNetworkDeviceConfiguration()
network.attachment = VZNATNetworkDeviceAttachment()
network.macAddress = VZMACAddress(string: "ce:a5:71:e0:00:01")!

config.networkDevices = [network]
config.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]

I pin a fixed local unicast MAC so the guest gets a stable DHCP lease run to run rather than a new one every boot. The VZNATNetworkDeviceAttachment gives me a vmnet shared NAT device, the guest sits behind the host on its own little subnet and gets a lease from the built in DHCP server. I originally wanted bridged networking so the VM would land directly on my LAN, but that uses VZBridgedNetworkDeviceAttachment, which needs the com.apple.vm.networking entitlement. Unlike the virtualization entitlement I’ll fight with later, that one is restricted, Apple has to grant it against a provisioning profile tied to a paid developer account, and you cannot just self sign it on. So NAT it is. Foreshadowing that this whole post is a story about which entitlements you are and aren’t allowed to hand yourself.

The last thing I setup was the virtio-console serial device which writes to the console reader so we can get the console output from the VM and write it to the terminal.

let console = VZVirtioConsoleDeviceSerialPortConfiguration()
console.attachment = VZFileHandleSerialPortAttachment(
  fileHandleForReading: nil,
  fileHandleForWriting: consoleWriteHandle // Console reader handler
)
config.serialPorts = [console]

This is the half of the console plumbing that lives on the host. The other half is the guest actually agreeing to talk on it. A VZVirtioConsoleDevice shows up inside Linux as hvc0 (hypervisor console 0), not the ttyS0/ttyAMA0 serial ports a normal kernel logs to by default. So if the guest kernel doesn’t know to use it, you wire up this whole pipe and 2 tenths of fuck all. The fix is one token on the kernel command line in Bingo:

console=tty0 console=${CONSOLE} console=hvc0 random.trust_cpu=on

While I was here I also stubbed in a VZVirtioSocketDeviceConfiguration. I’m not using it yet, but it wires up a vsock so I can do direct host to guest IPC later without going over the network. Future me problem.

Swift and this framework is quite nice and allow us to validate the config before we actually run it by doing a try config.validate(). But once we have a valid config then we can just do the following to run it:

let machine = VZVirtualMachine(configuration: config)
machine.delegate = self

try await machine.start()

This is all the boilerplate to run the system. The rest is just parsing the logging, waiting for signals and putting things into classes, the part of any project nobody cares about. Bingo, meanwhile, is still the same do-everything PID 1 monolith I swore I would replace with coffey, a proper Go init system that has been vaporware since the day I name dropped it.

Codesigning My Way In

I run swift run and it immediately shits the bed. The moment it tries to spin up the VM it dies, because macOS will not hand you a hypervisor unless the process carries the com.apple.security.virtualization entitlement. Xcode injects this for you behind the scenes when you hit the run button, which is exactly the kind of thing I signed up to do by hand when I decided to pick the smallest hard drive for my Mac Mini.

Fortunately, the entitlement itself is tiny, just a plist with one key set to true:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.virtualization</key>
  <true/>
</dict>
</plist>

This is the entitlement I mentioned earlier that you are allowed to give yourself, unlike the bridged networking one. You attach it to the binary with codesign. I don’t have a paid developer certificate and don’t need one just to run my own binary, so an ad hoc signature (--sign -) is enough:

bin="$(swift build --package-path host --show-bin-path)/host"
codesign --force --sign - --entitlements host/host.entitlements "$bin"

This is where swift run actually bites. It compiles and runs in a single step, so there is no window to slot codesign in between building the binary and executing it. The build produces an unsigned binary and immediately runs that unsigned binary, which is precisely the thing the kernel refuses to let near the Virtualization framework. So the justfile splits the two apart: just build does the swift build and then signs the binary it produced, while just run builds, signs, and only then execs the signed binary directly.

It Boots

With the binary signed, just run boots the guest headless and streams its console straight to my terminal. The host watches that console for the line where Bingo prints its DHCP lease, pulls the IP out of it, and tells me where the VM landed:

$ just run
[host] starting VM (cpu=1, mem=256MiB, image=bingo.arm64.img)
[host] VM running. Press Ctrl-C to shut down.
... kernel boot spam ...
virtio_blk virtio2: [vda] 18432 512-byte logical blocks (9.44 MB/9.00 MiB)
random: crng init done
Run /init as init process
time=1970-01-01T00:00:00.138Z level=INFO msg="mounting filesystems"
time=1970-01-01T00:00:00.139Z level=INFO msg="interface up" network.iface=eth0
time=1970-01-01T00:00:00.139Z level=INFO msg="starting DHCP exchange" network.iface=eth0
time=1970-01-01T00:00:00.152Z level=INFO msg="DHCP lease acquired" network.ip=192.168.64.3/24 network.gateway=192.168.64.1 network.dns=[192.168.64.1] network.lease_time=1h0m0s
[host] guest IP 192.168.64.3
time=1970-01-01T00:00:00.153Z level=INFO msg="server starting" app.addr=:8080

The whole boot finishes in about 150ms of guest time, which is a little ridiculous. Bingo thinks it’s 1970 because there is no clock to sync against, don’t worry about it, happens all the time. Importantly it has a lease and an HTTP server listening on :8080. So from a second terminal I do the thing this whole project existed to do:

# From another terminal
$ curl http://192.168.64.3:8080/
Hello, World!

And there it is. My tiny bootable image, the same one from Booting Go on Bare Metal, running inside Apple’s hypervisor and answering a request. Those two words have now greeted me from a scratch container, a tarball I recomputed the digests of by hand, bare QEMU metal, and a hypervisor. Watching the streamed console you can even see the request land on the guest:

time=1970-01-01T00:00:16.576Z level=INFO msg="request started" app.request_id=11bb499eaae12cc2 app.method=GET app.path=/ app.remote_addr=192.168.64.1:50343

The remote_addr is 192.168.64.1, the gateway, not my machine, because the NAT device rewrites everything to look like it came from the host side of the network. As far as the project’s stated goal goes, this is a win. I should probably have stopped reading my own logs here.

The Shutdown that Wouldn’t

Here is the part I glossed over. Hitting Ctrl-C did nothing useful. The host did its part perfectly. The signal gets trapped, hops back onto the main actor, and calls vm.requestStop(), which is the framework’s polite way of pressing the guest’s power button. Then it sets a 6 second timer, and if the guest hasn’t gone away by then, it gets terminated:

private func requestStop() {
  guard !stopping, let vm else { return }
  stopping = true
  note("requesting graceful shutdown (ACPI power button)...")

  try? vm.requestStop()

  Task { @MainActor in
    try? await Task.sleep(for: .seconds(6))
    note("guest did not stop in time; exiting")
    exit(1)
  }
}

Every single time, six seconds later, “guest did not stop in time”. The guest was very much alive, still answering HTTP, completely oblivious that the smoke alarm was blaring and it was time to away. It was getting executed, not shut down. The worst part was that the guest said absolutely nothing about it. No error, no warning, no log line. The host asked it to stop, the guest carried on, and the only evidence anything had happened was the host giving up after six seconds.

The annoying part is the guest already had all the code to handle this. It came over from the Booting Go on Bare Metal post, where Bingo scans /sys/class/input for a device named “Power Button”, reads 24 byte input_event structs off it, and watches for an EV_KEY / KEY_POWER press. On press it stops the HTTP server, syncs the filesystems, and calls reboot(POWER_OFF). That code was fine. The problem is the power button device never existed for it to find, so the listener had nothing to attach to and quietly went nowhere.

This is where Apple’s arm64 platform does something different to what my kernel was built for. On QEMU and most x86 hardware, the ACPI power button is a fixed hardware button and CONFIG_ACPI_BUTTON alone is enough to get a “Power Button” input device. On Apple’s virtual machine the power button is wired as a GPIO line on a PL061 controller, and the ACPI tables describe it with a GpioInt event that, when the host fires it, runs an ACPI method that Notify()s the button device. So the real chain is more along the lines of:

host requestStop()
  -> ACPI event
    -> PL061 GPIO line
      -> ACPI GPIO event handler
        -> Notify() the ACPI button device
          -> /dev/input/eventN "Power Button"
            -> my Go code reads KEY_POWER

My custom kernel had CONFIG_ACPI_BUTTON, the one I so confidently listed under “what we need” last post while gleefully deleting everything that wasn’t virtio, but it had no idea what a PL061 was. PL061 was collateral damage in that purge. Past me declared clean shutdown a solved problem requiring no external tools, and present me is reaping exactly that. The GPIO line was merely for show, the ACPI event stared blankly at the wall, and the button device was more of a concept. Bingo’s scan of /sys/class/input had no “Power Button” to find, so nothing downstream of it ever ran. requestStop() had been shooting blanks the whole time, which is exactly why the guest never had anything to say about it.

The fix was three lines in the arm64 kernel config:

CONFIG_GPIOLIB=y
CONFIG_GPIOLIB_ACPI=y
CONFIG_GPIO_PL061=y

Then several beers later the compilation of the kernel was done with what we needed. GPIO_PL061 is the driver for the actual controller, GPIOLIB_ACPI is the glue that lets ACPI event methods drive GPIO lines, and GPIOLIB is the subsystem both sit on top of. Rebuild the kernel, rebuild the image, and the boot log finally gains a line that simply was never there before:

time=1970-01-01T00:00:00.153Z level=INFO msg="listening for power button" acpi.device=/dev/input/event0

Now Ctrl-C does what it says. The host requests the stop, the guest catches fire safely and tears down the HTTP server, syncs, and powers off cleanly. The host’s guestDidStop delegate fires and it exits 0 well inside the 6 second grace window, no execution required.

So, Annoyingly Simple?

Mostly, yes. The Virtualization framework itself is genuinely simple. Resources, boot loader, disk, network, console, and validation are maybe sixty lines of Swift, and the first boot worked on basically the first honest attempt. I went in expecting to burn through a strip of nurofen and the framework gave me almost no reason to. The strip I saved did not stay saved for long. It went, in full, into Mango, a project I am not getting into here beyond saying it has caused more headaches than everything in this post combined. That one gets its own post.

What is funny is that both things that actually fought me lived in the same place, the seam between macOS and the guest, and both were really about permission. Getting in needed an entitlement I was allowed to grant myself (com.apple.security.virtualization, ad hoc signed), while the one I actually wanted for bridged networking I’m not (com.apple.vm.networking, Apple granted). Getting out cleanly needed the guest kernel to actually be wired for the way Apple’s hardware delivers the power button, which was three config lines and an afternoon of dicking about with a guest that gave me nothing to debug with because I refused to touch them.

No Xcode, no bridge networking, no developer certificate, just a signed binary, a 9MB disk image, and a kernel that finally knows what a power button is. Docker says ship your machine when it works on yours, this is just the gloriously wrong way round of doing exactly that. A man who will hand mangle a tarball at 1am to avoid standing up a registry has, predictably, ended up shipping an entire operating system as a 9MB file instead. The crusade continues. The hardest parts were both about asking nicely.

<< Previous Post

|

Next Post >>

#Swift #Virtualisation #Linux #Kernel