mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
docs: add macOS Windows UTM test setup
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Generated Windows/UTM artifacts — build these outside the repository when possible.
|
||||
*.iso
|
||||
*.img
|
||||
*.qcow2
|
||||
*.utm/
|
||||
*.log
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}')"
|
||||
Executable
+197
@@ -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 "$volume = Get-Volume | Where-Object { $_.FileSystemLabel -eq 'CAMOFOX_E2E' } | Select-Object -First 1; if (-not $volume) { throw 'CAMOFOX_E2E media is not mounted' }; & ""$($volume.DriveLetter):\provision.ps1"""</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}')"
|
||||
Executable
+92
@@ -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"
|
||||
Reference in New Issue
Block a user