docs: add macOS Windows UTM test setup

This commit is contained in:
Pradeep Elankumaran
2026-09-22 10:32:12 -07:00
parent fc93db3fda
commit 10e3de82d1
6 changed files with 599 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated Windows/UTM artifacts — build these outside the repository when possible.
*.iso
*.img
*.qcow2
*.utm/
*.log
__pycache__/
+78
View File
@@ -0,0 +1,78 @@
# Windows ARM64 UTM E2E runner (macOS)
This directory creates a **new, isolated** Windows 11 ARM64 virtual machine (VM) in [UTM](https://mac.getutm.app/) for reproducing Camofox Windows failures locally. It is developer tooling, not part of the Camofox runtime or CI job.
The GitHub Actions Windows job remains the normal test path. Use this only when a hosted Windows failure needs interactive or SSH-based investigation.
## What is checked in
- `build-installer-autoboot-iso.sh` creates a derived, verified copy of Microsoft's ARM64 installer with a UEFI fallback so UTM can boot it unattended.
- `build-fat-bootstrap.py` creates a UEFI-readable FAT32 bootstrap image from that installer.
- `build-provisioning-iso.sh` creates the one-time Windows Setup answer ISO and embeds **one supplied public SSH key**.
- `create-vm.sh` assembles those inputs into a new UTM bundle with an isolated sparse NVMe disk and loopback-only SSH forwarding.
Do not commit the Windows installer, generated ISOs or images, UTM bundles, VM disks, UEFI/TPM state, debug logs, or SSH keys.
## Requirements
- macOS on Apple silicon
- UTM installed in `/Applications/UTM.app`
- `qemu-img` on `PATH` (for example, `brew install qemu`)
- Python 3
- A Microsoft Windows 11 ARM64 ISO whose SHA-256 is:
```text
638AA2C88E94385B00F4F178D071E3DF0B7D9E335577A83BD533B7F2EB65ADF0
```
The scripts refuse another image rather than silently using an unreviewed Windows build.
## Create the VM
Run these commands from the repository root. Choose an artifact directory outside the repository; generated files are large.
```sh
mkdir -p "$HOME/Downloads/camofox-windows-e2e"
ssh-keygen -t ed25519 -f "$HOME/.ssh/camofox-windows-e2e" -C camofox-windows-e2e
# Replace this with the verified Microsoft ARM64 installer you downloaded.
WINDOWS_ISO="$HOME/Downloads/Win11_25H2_English_Arm64_v2.iso"
ARTIFACTS="$HOME/Downloads/camofox-windows-e2e"
# The derived installer retains Microsoft's files and adds only startup.nsh.
tools/windows-utm/build-installer-autoboot-iso.sh \
--source "$WINDOWS_ISO" \
--output "$ARTIFACTS/windows-installer.iso"
# This is for UTM firmware that cannot read the Windows UDF installer directly.
tools/windows-utm/build-fat-bootstrap.py \
--source "$ARTIFACTS/windows-installer.iso" \
--output "$ARTIFACTS/windows-fat-bootstrap.img"
# New-VM-only answer ISO. It embeds the public key, never the private key.
tools/windows-utm/build-provisioning-iso.sh \
--public-key "$HOME/.ssh/camofox-windows-e2e.pub" \
--output "$ARTIFACTS/camofox-windows-provisioning.iso"
# Creates a new VM; it refuses to overwrite an existing bundle.
tools/windows-utm/create-vm.sh \
--installer "$ARTIFACTS/windows-installer.iso" \
--provisioning "$ARTIFACTS/camofox-windows-provisioning.iso" \
--bootstrap "$ARTIFACTS/windows-fat-bootstrap.img"
```
The final command opens UTM. Let Windows Setup finish. It creates the unprivileged `camofox-e2e` account, installs OpenSSH Server, and forwards guest port 22 only to `127.0.0.1:22222` on the Mac.
Once Windows is up, verify SSH:
```sh
ssh -i "$HOME/.ssh/camofox-windows-e2e" -p 22222 camofox-e2e@127.0.0.1
```
Then use that SSH session to run the bounded Windows test or inspect the Windows setup log at `C:\ProgramData\CamofoxE2E\provision.log`.
## Safety
`build-provisioning-iso.sh` creates an `Autounattend.xml` that wipes and partitions **disk 0**. Use its result only as installation media for a freshly created VM. Never attach it to an existing Windows machine or VM.
The VM creation script never overwrites an existing UTM bundle, and its SSH forward is loopback-only. Delete the entire generated UTM bundle and the artifact directory when the investigation is complete.
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Build a UEFI-readable FAT32 Windows PE bootstrap from a UDF Windows ISO."""
import argparse, binascii, math, os, struct
from pathlib import Path, PurePosixPath
BLOCK = 2048
def udf_tree(image):
data = image.read_bytes()
# Locate the File Set Descriptor and physical UDF partition; Microsoft's
# source ISO and derived images place these at different logical blocks.
partition_start = None
file_set = None
for block in range(16, min(len(data) // BLOCK, 1024)):
offset = block * BLOCK
tag = struct.unpack_from('<H', data, offset)[0]
if tag == 5 and partition_start is None:
partition_start = struct.unpack_from('<I', data, offset + 188)[0]
elif tag == 256 and file_set is None:
file_set = offset
if partition_start is None or file_set is None:
raise ValueError('source does not contain a readable UDF partition and File Set Descriptor')
def entry(logical):
off = (partition_start + logical) * BLOCK
d = data[off:off + BLOCK]
if len(d) != BLOCK or struct.unpack_from('<H', d, 0)[0] != 261:
raise ValueError(f'expected UDF file entry at logical block {logical}')
size = struct.unpack_from('<Q', d, 56)[0]
ea, adlen = struct.unpack_from('<II', d, 168)
chunks = []
for at in range(176 + ea, 176 + ea + adlen, 8):
length, location = struct.unpack_from('<II', d, at)
chunks.append((length, (partition_start + location) * BLOCK))
return d[27] == 4, size, chunks
def contents(chunks):
return b''.join(data[offset:offset + length] for length, offset in chunks)
files = {}
def visit(logical, prefix):
is_dir, size, chunks = entry(logical)
if not is_dir:
files[prefix] = contents(chunks)[:size]
return
directory = contents(chunks)[:size]
at = 0
while at + 38 <= len(directory) and struct.unpack_from('<H', directory, at)[0] == 257:
_, flags, name_len = struct.unpack_from('<HBB', directory, at + 16)
_, child, _ = struct.unpack_from('<IIH', directory, at + 20)
impl_len = struct.unpack_from('<H', directory, at + 36)[0]
encoded = directory[at + 38 + impl_len:at + 38 + impl_len + name_len]
at = (at + 38 + impl_len + name_len + 3) & ~3
if flags & 8: continue
if not encoded: raise ValueError('empty UDF filename')
if encoded[0] == 8:
name = encoded[1:].decode('latin1')
elif encoded[0] == 16:
name = encoded[1:].decode('utf-16-be')
else:
raise ValueError(f'unsupported UDF filename encoding: {encoded[0]}')
visit(child, prefix / name)
# Root directory ICB is stored in the File Set Descriptor at byte 400.
root = struct.unpack_from('<I', data, file_set + 404)[0]
visit(root, PurePosixPath())
return files
def short_name(name):
name = name.upper()
if name in ('.', '..'): return name.ljust(11).encode('ascii')
stem, dot, ext = name.partition('.')
allowed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$%\'-_@~`!(){}^#&'
if not stem or len(stem) > 8 or len(ext) > 3 or any(c not in allowed for c in stem + ext):
# All included long names are unique in their directory; this standard
# 8.3 alias is paired with a long-file-name entry below.
stem = (stem[:6] + '~1')[:8]
ext = ext[:3]
return (stem.ljust(8) + ext.ljust(3)).encode('ascii')
def lfn_entries(name, alias):
encoded = name.encode('utf-16le') + b'\0\0'
units = [encoded[i:i + 2] for i in range(0, len(encoded), 2)]
units += [b'\xff\xff'] * ((13 - len(units) % 13) % 13)
chunks = [units[i:i + 13] for i in range(0, len(units), 13)]
checksum = 0
for value in alias: checksum = ((checksum & 1) << 7) + (checksum >> 1) + value & 0xff
entries = []
for index in range(len(chunks), 0, -1):
e = bytearray(32); e[0] = index | (0x40 if index == len(chunks) else 0); e[11] = 0x0f; e[13] = checksum
chunk = b''.join(chunks[index - 1]); e[1:11] = chunk[:10]; e[14:26] = chunk[10:22]; e[28:32] = chunk[22:26]
entries.append(e)
return entries
def build(output, files):
# 1 GiB: Windows PE boot.wim plus complete EFI/BOOT trees fit with ample room.
total_sectors, spc, reserved, fats = 2 * 1024 * 1024, 8, 32, 2
fat_sectors = 1
while True:
clusters = (total_sectors - reserved - fats * fat_sectors) // spc
next_size = math.ceil((clusters + 2) * 4 / 512)
if next_size == fat_sectors: break
fat_sectors = next_size
paths = {PurePosixPath()}
for p in files:
paths.update(p.parents)
dirs = sorted(paths, key=lambda p: (len(p.parts), str(p)))
cluster = 2
allocation = {}
for d in dirs:
allocation[d] = (cluster, 1); cluster += 1
for p, content in sorted(files.items(), key=lambda x: str(x[0])):
count = max(1, math.ceil(len(content) / (spc * 512)))
allocation[p] = (cluster, count); cluster += count
if cluster > clusters + 2: raise ValueError('bootstrap contents exceed image capacity')
fat = [0] * (clusters + 2); fat[0] = 0x0ffffff8; fat[1] = 0x0fffffff
for start, count in allocation.values():
for n in range(count): fat[start + n] = 0x0fffffff if n == count - 1 else start + n + 1
first_data = reserved + fats * fat_sectors
def offset(c): return (first_data + (c - 2) * spc) * 512
def directory(path):
entries = bytearray()
if path != PurePosixPath():
parent = path.parent
for name, target in (('.', path), ('..', parent)):
e = bytearray(32); e[:11] = short_name(name); e[11] = 0x10
struct.pack_into('<H', e, 20, allocation[target][0] >> 16); struct.pack_into('<H', e, 26, allocation[target][0] & 0xffff); entries += e
children = sorted([p for p in allocation if p.parent == path and p != path], key=lambda x: x.name.upper())
for child in children:
start, count = allocation[child]; alias = short_name(child.name)
if alias.rstrip().decode('ascii') != child.name.upper(): entries += b''.join(lfn_entries(child.name, alias))
e = bytearray(32); e[:11] = alias; e[11] = 0x10 if child in paths else 0x20
struct.pack_into('<H', e, 20, start >> 16); struct.pack_into('<H', e, 26, start & 0xffff)
if child in files: struct.pack_into('<I', e, 28, len(files[child]))
entries += e
if len(entries) > spc * 512: raise ValueError(f'directory too large: {path}')
return entries
with output.open('wb') as f:
f.truncate(total_sectors * 512)
boot = bytearray(512); boot[:3] = b'\xebX\x90'; boot[3:11] = b'MSWIN4.1'; struct.pack_into('<H', boot, 11, 512); boot[13] = spc; struct.pack_into('<H', boot, 14, reserved); boot[16] = fats; struct.pack_into('<I', boot, 32, total_sectors); boot[21] = 0xf8; struct.pack_into('<I', boot, 36, fat_sectors); struct.pack_into('<I', boot, 44, 2); boot[510:512] = b'\x55\xaa'; f.write(boot)
f.seek(6 * 512); fsinfo = bytearray(512); fsinfo[:4] = b'RRaA'; fsinfo[484:488] = b'rrAa'; struct.pack_into('<I', fsinfo, 488, clusters - (cluster - 2)); struct.pack_into('<I', fsinfo, 492, cluster); fsinfo[510:512] = b'\x55\xaa'; f.write(fsinfo)
fat_bytes = struct.pack('<' + 'I' * len(fat), *fat)
for n in range(fats): f.seek((reserved + n * fat_sectors) * 512); f.write(fat_bytes)
for d in dirs: f.seek(offset(allocation[d][0])); f.write(directory(d))
for p, content in files.items(): f.seek(offset(allocation[p][0])); f.write(content)
def main():
ap = argparse.ArgumentParser(); ap.add_argument('--source', type=Path, required=True); ap.add_argument('--output', type=Path, required=True); args = ap.parse_args()
source = udf_tree(args.source)
selected = {p: data for p, data in source.items() if str(p).lower().startswith(('efi/', 'boot/')) or str(p).lower() in ('bootmgr.efi', 'bootmgfw.efi', 'sources/boot.wim')}
required = [PurePosixPath('efi/boot/bootaa64.efi'), PurePosixPath('efi/microsoft/boot/bcd'), PurePosixPath('sources/boot.wim')]
if any(p not in selected for p in required): raise SystemExit('source ISO lacks required ARM64 Windows PE boot files')
args.output.parent.mkdir(parents=True, exist_ok=True); build(args.output, selected)
print(f'Created {args.output} with {len(selected)} files ({args.output.stat().st_size} bytes).')
if __name__ == '__main__': main()
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Build a derived first-boot copy of Microsoft's Windows ARM64 ISO for UTM.
# The original ISO remains unmodified and is verified before its contents are copied.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: tools/windows-utm/build-installer-autoboot-iso.sh --source PATH --output PATH
Creates a UDF/ISO copy of the verified Windows 11 ARM64 installer with a root
startup.nsh. If UTM's blank ARM UEFI store falls into its built-in shell, the
script starts the installer’s existing signed EFI boot application without any
host or guest input automation.
EOF
}
source_iso=''
output=''
while [[ $# -gt 0 ]]; do
case "$1" in
--source) source_iso=${2:?missing source ISO}; shift 2 ;;
--output) output=${2:?missing output ISO}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
[[ -n "$source_iso" && -n "$output" ]] || { usage >&2; exit 2; }
[[ -f "$source_iso" ]] || { echo "Source ISO does not exist: $source_iso" >&2; exit 2; }
command -v hdiutil >/dev/null || { echo 'hdiutil is required (macOS).' >&2; exit 2; }
expected='638AA2C88E94385B00F4F178D071E3DF0B7D9E335577A83BD533B7F2EB65ADF0'
actual=$(shasum -a 256 "$source_iso" | awk '{print toupper($1)}')
[[ "$actual" == "$expected" ]] || { echo 'Source ISO checksum does not match the verified Microsoft Windows 11 ARM64 ISO.' >&2; exit 2; }
output=$(cd "$(dirname "$output")" && pwd)/$(basename "$output")
[[ "$output" == *.iso ]] || { echo 'Output filename must end in .iso.' >&2; exit 2; }
[[ ! -e "$output" ]] || { echo "Refusing to overwrite existing output: $output" >&2; exit 2; }
work=$(mktemp -d)
mount="$work/mount"
stage="$work/stage"
mkdir -p "$mount" "$stage"
cleanup() {
mount | grep -Fq "on $mount " && hdiutil detach "$mount" >/dev/null 2>&1 || true
chmod -R u+w "$work" 2>/dev/null || true
rm -rf "$work"
}
trap cleanup EXIT
hdiutil attach -readonly -nobrowse "$source_iso" -mountpoint "$mount" >/dev/null
# ditto preserves the Windows installer tree while adding only the UEFI-shell fallback.
ditto "$mount" "$stage"
chmod -R u+w "$stage"
# UEFI Shell resolves startup.nsh only from its current mapped filesystem. Probe
# all expected removable mappings so this remains valid when UTM changes USB
# enumeration order between boots.
cat > "$stage/startup.nsh" <<'EOF'
map -r
if exist fs0:\EFI\BOOT\BOOTAA64.EFI then
fs0:
\EFI\BOOT\BOOTAA64.EFI
endif
if exist fs1:\EFI\BOOT\BOOTAA64.EFI then
fs1:
\EFI\BOOT\BOOTAA64.EFI
endif
if exist fs2:\EFI\BOOT\BOOTAA64.EFI then
fs2:
\EFI\BOOT\BOOTAA64.EFI
endif
EOF
# A read-only ISO can be auto-detached after the copy on some macOS versions.
# The EXIT cleanup also detaches it when it remains mounted.
hdiutil detach "$mount" >/dev/null 2>&1 || true
hdiutil makehybrid -udf -iso -joliet -default-volume-name WIN11_ARM64_UTM -o "$output" "$stage" >/dev/null
printf 'Created %s\nSHA-256 %s\n' "$output" "$(shasum -a 256 "$output" | awk '{print $1}')"
+197
View File
@@ -0,0 +1,197 @@
#!/bin/bash
# Build a Windows Setup answer ISO that provisions loopback-forwarded SSH for UTM E2E.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: tools/windows-utm/build-provisioning-iso.sh --public-key PATH --output PATH
Creates a first-install-only Windows answer ISO. The public key is embedded in the
ISO; no private key is read, copied, or printed.
EOF
}
public_key_file=''
output=''
while [[ $# -gt 0 ]]; do
case "$1" in
--public-key) public_key_file=${2:-}; shift 2 ;;
--output) output=${2:-}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
[[ -n "$public_key_file" && -n "$output" ]] || { usage >&2; exit 2; }
[[ -f "$public_key_file" ]] || { echo "Public-key file does not exist: $public_key_file" >&2; exit 2; }
command -v hdiutil >/dev/null || { echo 'hdiutil is required (macOS).' >&2; exit 2; }
public_key=$(tr -d '\r\n' < "$public_key_file")
if [[ ! "$public_key" =~ ^(ssh-ed25519|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh.com|sk-ecdsa-sha2-nistp256@openssh.com|ssh-rsa)[[:space:]][A-Za-z0-9+/=]+([[:space:]].*)?$ ]]; then
echo 'Expected one single-line OpenSSH public key.' >&2
exit 2
fi
output=$(cd "$(dirname "$output")" && pwd)/$(basename "$output")
[[ "$output" == *.iso ]] || { echo 'Output filename must end in .iso.' >&2; exit 2; }
mkdir -p "$(dirname "$output")"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
printf '%s\n' "$public_key" > "$work/camofox-e2e.pub"
cat > "$work/Autounattend.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<!-- This answer file only ever targets disk 0 of the newly created UTM VM. -->
<settings pass="windowsPE">
<component name="Microsoft-Windows-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>260</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>FAT32</Format><Label>System</Label></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>3</PartitionID><Format>NTFS</Format><Label>Windows</Label><Letter>C</Letter></ModifyPartition>
</ModifyPartitions>
</Disk>
<WillShowUI>OnError</WillShowUI>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
<InstallToAvailablePartition>false</InstallToAvailablePartition>
<WillShowUI>OnError</WillShowUI>
</OSImage>
</ImageInstall>
<UserData>
<!-- Microsoft’s public generic Pro setup key selects the edition; it does not activate Windows. -->
<ProductKey><Key>VK7JG-NPHTM-C97JM-9MPGT-3V66T</Key><WillShowUI>OnError</WillShowUI></ProductKey>
<AcceptEula>true</AcceptEula>
<FullName>Camofox E2E</FullName>
<Organization>Camofox</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Deployment" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<Description>Provision Camoufox Windows E2E SSH</Description>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command &quot;$volume = Get-Volume | Where-Object { $_.FileSystemLabel -eq 'CAMOFOX_E2E' } | Select-Object -First 1; if (-not $volume) { throw 'CAMOFOX_E2E media is not mounted' }; &amp; &quot;&quot;$($volume.DriveLetter):\provision.ps1&quot;&quot;&quot;</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
</component>
</settings>
</unattend>
EOF
cat > "$work/provision.ps1" <<'EOF'
$ErrorActionPreference = 'Stop'
$root = 'C:\ProgramData\CamofoxE2E'
$log = Join-Path $root 'provision.log'
$taskName = 'CamofoxE2E-InstallOpenSSH'
New-Item -ItemType Directory -Path $root -Force | Out-Null
function Write-Log([string]$Message) { "$(Get-Date -Format o) $Message" | Tee-Object -FilePath $log -Append }
function Install-OpenSsh {
$capability = Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
if ($capability.State -ne 'Installed') {
Write-Log "Installing OpenSSH Server capability (state: $($capability.State))."
Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null
}
}
function Configure-Ssh {
$keyPath = Join-Path $root 'camofox-e2e_authorized_keys'
if (-not (Test-Path $keyPath)) { throw "Missing authorized key: $keyPath" }
$user = Get-LocalUser -Name 'camofox-e2e' -ErrorAction SilentlyContinue
if (-not $user) {
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$password = ConvertTo-SecureString (([Convert]::ToBase64String($bytes)) + 'aA1!') -AsPlainText -Force
New-LocalUser -Name 'camofox-e2e' -Password $password -AccountNeverExpires -Description 'Camofox Windows E2E runner' | Out-Null
Remove-Variable password, bytes
Write-Log 'Created the camofox-e2e local account with a random non-recoverable password.'
}
$config = 'C:\ProgramData\ssh\sshd_config'
$marker = '# Camofox Windows E2E provisioning'
if (-not (Select-String -Path $config -SimpleMatch $marker -Quiet -ErrorAction SilentlyContinue)) {
Add-Content -Path $config -Value @"
$marker
PasswordAuthentication no
PubkeyAuthentication yes
Match User camofox-e2e
AuthorizedKeysFile C:/ProgramData/CamofoxE2E/camofox-e2e_authorized_keys
"@
}
& icacls $root /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
if (-not (Get-NetFirewallRule -DisplayName 'Camofox E2E SSH' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'Camofox E2E SSH' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 22 | Out-Null
}
Set-Service sshd -StartupType Automatic
Restart-Service sshd -Force
}
# On first execution, retain the script and the public key locally before Setup ejects this ISO.
$keyPath = Join-Path $root 'camofox-e2e_authorized_keys'
if (-not (Test-Path $keyPath)) {
$volume = Get-Volume | Where-Object { $_.FileSystemLabel -eq 'CAMOFOX_E2E' } | Select-Object -First 1
if (-not $volume) { throw 'CAMOFOX_E2E media is not mounted' }
Copy-Item "$($volume.DriveLetter):\camofox-e2e.pub" $keyPath -Force
Copy-Item $PSCommandPath (Join-Path $root 'provision.ps1') -Force
}
# Retry during subsequent boots if Windows Update was not ready in specialize.
$action = New-ScheduledTaskAction -Execute 'PowerShell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File $root\provision.ps1"
$trigger = New-ScheduledTaskTrigger -AtStartup
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
try {
Install-OpenSsh
Configure-Ssh
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Log 'Provisioning complete. OpenSSH is ready for key-only camofox-e2e access.'
} catch {
Write-Log "Provisioning deferred: $($_.Exception.Message)"
exit 0
}
EOF
# The UEFI shell searches its current mapped filesystem for startup.nsh. Put an
# identical fallback on this secondary ISO too, then probe every removable
# filesystem explicitly so UTM's firmware mapping order cannot matter.
cat > "$work/startup.nsh" <<'EOF'
map -r
if exist fs0:\EFI\BOOT\BOOTAA64.EFI then
fs0:
\EFI\BOOT\BOOTAA64.EFI
endif
if exist fs1:\EFI\BOOT\BOOTAA64.EFI then
fs1:
\EFI\BOOT\BOOTAA64.EFI
endif
if exist fs2:\EFI\BOOT\BOOTAA64.EFI then
fs2:
\EFI\BOOT\BOOTAA64.EFI
endif
EOF
rm -f "$output"
hdiutil makehybrid -iso -joliet -default-volume-name CAMOFOX_E2E -o "$output" "$work" >/dev/null
printf 'Created %s\nSHA-256 %s\n' "$output" "$(shasum -a 256 "$output" | awk '{print $1}')"
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# Create and register an isolated Windows 11 ARM64 UTM VM for Camoufox E2E.
# Does not read from or modify any existing UTM VM bundle.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: tools/windows-utm/create-vm.sh --installer PATH --provisioning PATH --bootstrap PATH [--output PATH] [--ssh-port PORT]
Creates a new UTM bundle with a fresh sparse NVMe disk and copies a verified
Windows installer, CAMOFOX_E2E provisioning ISO, and FAT bootstrap image into
that bundle. Existing VMs are never cloned or changed.
EOF
}
output="$HOME/Library/Containers/com.utmapp.UTM/Data/Documents/Camofox Windows E2E.utm"
installer=''
provisioning=''
bootstrap=''
ssh_port=22222
while [[ $# -gt 0 ]]; do
case "$1" in
--installer) installer=${2:?missing installer path}; shift 2 ;;
--provisioning) provisioning=${2:?missing provisioning ISO path}; shift 2 ;;
--bootstrap) bootstrap=${2:?missing FAT bootstrap image path}; shift 2 ;;
--output) output=${2:?missing output path}; shift 2 ;;
--ssh-port) ssh_port=${2:?missing SSH port}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
firmware='/Applications/UTM.app/Contents/Resources/qemu/edk2-arm-vars.fd'
qemu_img="$(command -v qemu-img || true)"
expected_installer='638AA2C88E94385B00F4F178D071E3DF0B7D9E335577A83BD533B7F2EB65ADF0'
[[ -n "$installer" && -n "$provisioning" && -n "$bootstrap" ]] || { usage >&2; exit 2; }
[[ "$ssh_port" =~ ^[1-9][0-9]{0,4}$ ]] && ((ssh_port <= 65535)) || { echo 'SSH port must be 1 through 65535.' >&2; exit 2; }
for file in "$installer" "$provisioning" "$bootstrap" "$firmware" "$qemu_img"; do
[[ -e "$file" ]] || { echo "Required file is missing: $file" >&2; exit 2; }
done
[[ ! -e "$output" ]] || { echo "Refusing to overwrite existing VM bundle: $output" >&2; exit 2; }
[[ "$(shasum -a 256 "$installer" | awk '{print toupper($1)}')" == "$expected_installer" ]] || { echo 'Windows installer checksum does not match Microsoft’s published English ARM64 value.' >&2; exit 2; }
if lsof -nP -iTCP:"$ssh_port" -sTCP:LISTEN >/dev/null 2>&1; then
echo "Host TCP port $ssh_port is already in use." >&2
exit 2
fi
uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
disk_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
installer_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
provisioning_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
bootstrap_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
random_byte() { od -An -N1 -tu1 /dev/urandom | tr -d '[:space:]'; }
mac=$(printf '02:%02X:%02X:%02X:%02X:%02X' "$(random_byte)" "$(random_byte)" "$(random_byte)" "$(random_byte)" "$(random_byte)")
mkdir -p "$output/Data"
trap 'rm -rf "$output"' ERR INT TERM
cp -p "$installer" "$output/Data/windows-installer.iso"
cp -p "$provisioning" "$output/Data/camofox-provisioning.iso"
cp -p "$bootstrap" "$output/Data/windows-fat-bootstrap.img"
cp -p "$firmware" "$output/Data/efi_vars.fd"
"$qemu_img" create -f qcow2 "$output/Data/$disk_uuid.qcow2" 80G >/dev/null
python3 - "$output/config.plist" "$uuid" "$disk_uuid" "$installer_uuid" "$provisioning_uuid" "$bootstrap_uuid" "$mac" "$ssh_port" <<'PY'
import plistlib, sys
path, uuid, disk, install_cd, provision_cd, bootstrap_disk, mac, port = sys.argv[1:]
def drive(identifier, name, image_type, interface, read_only=False):
return {'Identifier': identifier, 'ImageName': name, 'ImageType': image_type,
'Interface': interface, 'InterfaceVersion': 1, 'ReadOnly': read_only}
config = {
'Backend': 'QEMU', 'ConfigurationVersion': 4,
'Information': {'Icon': 'windows', 'IconCustom': False, 'Name': 'Camofox Windows E2E', 'UUID': uuid},
'System': {'Architecture': 'aarch64', 'CPU': 'default', 'CPUCount': 8, 'CPUFlagsAdd': [], 'CPUFlagsRemove': [],
'ForceMulticore': False, 'JITCacheSize': 0, 'MemorySize': 16384, 'Target': 'virt'},
'QEMU': {'AdditionalArguments': [], 'BalloonDevice': False, 'DebugLog': True, 'Hypervisor': True,
'PS2Controller': False, 'RNGDevice': True, 'RTCLocalTime': True, 'TPMDevice': True, 'TSO': False, 'UEFIBoot': True},
'Input': {'MaximumUsbShare': 3, 'UsbBusSupport': '3.0', 'UsbSharing': False},
'Sharing': {'ClipboardSharing': False, 'DirectoryShareMode': 'None', 'DirectoryShareReadOnly': True},
'Display': [{'DownscalingFilter': 'Linear', 'DynamicResolution': False, 'Hardware': 'virtio-ramfb-gl', 'NativeResolution': False, 'UpscalingFilter': 'Nearest'}],
'Drive': [drive(bootstrap_disk, 'windows-fat-bootstrap.img', 'Disk', 'USB', True), drive(provision_cd, 'camofox-provisioning.iso', 'CD', 'USB', True), drive(install_cd, 'windows-installer.iso', 'Disk', 'USB', True), drive(disk, disk + '.qcow2', 'Disk', 'NVMe')],
'Network': [{'Hardware': 'virtio-net-pci', 'IsolateFromHost': False, 'MacAddress': mac, 'Mode': 'Emulated',
'PortForward': [{'Protocol': 'TCP', 'HostAddress': '127.0.0.1', 'HostPort': int(port), 'GuestAddress': '127.0.0.1', 'GuestPort': 22}]}],
'Serial': [], 'Sound': [{'Hardware': 'intel-hda'}]
}
with open(path, 'wb') as f: plistlib.dump(config, f, sort_keys=False)
PY
plutil -lint "$output/config.plist" >/dev/null
"$qemu_img" check "$output/Data/$disk_uuid.qcow2" >/dev/null
trap - ERR INT TERM
printf 'Created isolated UTM bundle: %s\nUUID: %s\nSSH: ssh -i ~/.ssh/camofox-windows-e2e -p %s camofox-e2e@127.0.0.1\n' "$output" "$uuid" "$ssh_port"
open -g -a UTM "$output"