diff --git a/.devcontainer/060 - macOS Catalina/devcontainer.json b/.devcontainer/060 - macOS Catalina/devcontainer.json deleted file mode 100644 index a5d7169..0000000 --- a/.devcontainer/060 - macOS Catalina/devcontainer.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "macOS 10 (Catalina)", - "service": "macos", - "containerEnv": { - "VERSION": "10" - }, - "forwardPorts": [8006], - "portsAttributes": { - "8006": { - "label": "Web", - "onAutoForward": "notify" - } - }, - "otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "dockerComposeFile": "../codespaces.yml", - "initializeCommand": "docker system prune --all --force" -} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3f30c28..735e33a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,7 +29,7 @@ jobs: uses: hadolint/hadolint-action@v3.5.0 with: dockerfile: Dockerfile - ignore: DL3008,DL3018,DL3020,DL3067 + ignore: DL3008,DL3018,DL3020,DL3067,DL4006 failure-threshold: warning - name: Validate JSON and YML files diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..47a7e51 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,151 @@ +name: Build install packages + +on: + workflow_dispatch: + push: + branches: + - dev + paths: + - '.github/workflows/package.yml' + - 'src/install/admin/**' + - 'src/install/setup/**' + - 'assets/install/setup/**' + +jobs: + package: + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build install packages + shell: bash + run: | + set -Eeuo pipefail + + work="$RUNNER_TEMP/macos-packages" + rm -rf "$work" + mkdir -p \ + "$work/admin/root/private/tmp" \ + "$work/admin/scripts" \ + "$work/setup/root/private/var/db" \ + "$work/setup/root/Library/User Template/English.lproj" \ + "$work/setup/scripts" \ + assets/install/package + + # Match pycreateuserpkg's known-working startosinstall layout: + # a real (but effectively empty) payload plus the account logic in scripts. + chmod 1777 "$work/admin/root/private/tmp" + + python3 src/install/admin/create.py \ + "$work/admin/scripts" \ + admin \ + admin \ + Y + + cp src/install/admin/postinstall "$work/admin/scripts/postinstall" + chmod 0755 "$work/admin/scripts/postinstall" "$work/admin/scripts/config" + chmod 0600 "$work/admin/scripts/user.plist" + [ ! -f "$work/admin/scripts/kcpassword" ] || chmod 0600 "$work/admin/scripts/kcpassword" + + python3 src/install/admin/validate.py \ + "$work/admin/scripts/user.plist" \ + admin + + pkgbuild \ + --root "$work/admin/root" \ + --ownership recommended \ + --identifier com.macos.install.admin \ + --version 1.0 \ + --scripts "$work/admin/scripts" \ + "$work/admin/component.pkg" + + productbuild \ + --package "$work/admin/component.pkg" \ + --identifier com.macos.install.admin \ + --version 1.0 \ + assets/install/package/admin.pkg + + # Put the two critical Setup Assistant markers in the payload itself. + # The postinstall remains responsible for generating the version-specific + # SetupAssistant preference plist. + touch "$work/setup/root/private/var/db/.AppleSetupDone" + touch "$work/setup/root/Library/User Template/English.lproj/.skipbuddy" + chmod 0644 \ + "$work/setup/root/private/var/db/.AppleSetupDone" \ + "$work/setup/root/Library/User Template/English.lproj/.skipbuddy" + + cp src/install/setup/postinstall "$work/setup/scripts/postinstall" + cp assets/install/setup/com.apple.SetupAssistant.plist \ + "$work/setup/scripts/com.apple.SetupAssistant.plist" + chmod 0755 "$work/setup/scripts/postinstall" + chmod 0644 "$work/setup/scripts/com.apple.SetupAssistant.plist" + + bash -n "$work/setup/scripts/postinstall" + + pkgbuild \ + --root "$work/setup/root" \ + --ownership recommended \ + --identifier com.macos.install.skipsetup \ + --version 1.0 \ + --scripts "$work/setup/scripts" \ + "$work/setup/component.pkg" + + productbuild \ + --package "$work/setup/component.pkg" \ + --identifier com.macos.install.skipsetup \ + --version 1.0 \ + assets/install/package/skipsetup.pkg + + - name: Verify install packages + shell: bash + run: | + set -Eeuo pipefail + + for package in admin skipsetup; do + file="assets/install/package/$package.pkg" + [ -s "$file" ] + + listing=$(xar -tf "$file") + grep -Fxq 'Distribution' <<< "$listing" + grep -Fxq 'component.pkg/PackageInfo' <<< "$listing" + grep -Fxq 'component.pkg/Payload' <<< "$listing" + grep -Fxq 'component.pkg/Scripts' <<< "$listing" + + dir="$RUNNER_TEMP/verify-$package" + rm -rf "$dir" + mkdir -p "$dir" + (cd "$dir" && xar -xf "$GITHUB_WORKSPACE/$file") + + grep -Fq "/dev/null) + grep -Fxq './postinstall' <<< "$scripts_listing" + + payload_listing=$(gzip -dc "$dir/component.pkg/Payload" | cpio -it 2>/dev/null) + + case "$package" in + admin) + grep -Fxq './private/tmp' <<< "$payload_listing" + ;; + skipsetup) + grep -Fxq './private/var/db/.AppleSetupDone' <<< "$payload_listing" + grep -Fxq './Library/User Template/English.lproj/.skipbuddy' <<< "$payload_listing" + ;; + esac + done + + shasum -a 256 \ + assets/install/package/admin.pkg \ + assets/install/package/skipsetup.pkg + + - name: Upload install packages + uses: actions/upload-artifact@v4 + with: + name: macos-install-packages-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + path: | + assets/install/package/admin.pkg + assets/install/package/skipsetup.pkg diff --git a/Dockerfile b/Dockerfile index 1250909..2625356 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.19 FROM scratch AS base -COPY --from=qemux/qemu:7.50 / / +COPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / / ARG VERSION_ARG="0.0" ARG VERSION_VM_HIDE="2.0.0" @@ -23,15 +23,39 @@ RUN < /usr/local/bin/macserial + # Keep matching official RELEASE and DEBUG EFI trees so runtime can switch + # between normal booting and OpenCore diagnostics without another image build. + for build in RELEASE DEBUG; do + flavour=$(printf '%s' "$build" | tr '[:upper:]' '[:lower:]') + archive="/tmp/opencore-$flavour.zip" + extract="/tmp/opencore-$flavour" + + wget "$REPO_OPENCORE/releases/download/$VERSION_OPENCORE/OpenCore-$VERSION_OPENCORE-$build.zip" \ + -O "$archive" -q --timeout=30 + + unzip -q "$archive" 'X64/EFI/*' -d "$extract" + mkdir -p "/opencore/$flavour" + cp -a "$extract/X64/EFI" "/opencore/$flavour/EFI" + + [ -s "/opencore/$flavour/EFI/BOOT/BOOTx64.efi" ] + [ -s "/opencore/$flavour/EFI/OC/OpenCore.efi" ] + done + + # Extract macserial from the matching official release. + unzip -p /tmp/opencore-release.zip Utilities/macserial/macserial.linux > /usr/local/bin/macserial chmod 755 /usr/local/bin/macserial + printf '%s\n' "$VERSION_OPENCORE" > /opencore/version # Set version file echo "$VERSION_ARG" > /etc/version @@ -41,6 +65,10 @@ EOF COPY --chmod=755 ./src /run/ COPY --chmod=755 ./assets /assets/ +COPY --from=qemux/qemu-macos:latest /usr/bin/qemu-system-x86_64 /usr/bin/ +COPY --from=qemux/qemu-macos:latest /usr/share/qemu/reims-vgpu-gop.rom /usr/share/qemu/ + +ADD --chmod=755 https://github.com/qemus/qemu-macos/releases/latest/download/air-dis /usr/local/bin/air-dis ADD --chmod=644 \ $REPO_OSX_KVM/$VERSION_OSX_KVM/OVMF_CODE.fd \ @@ -56,7 +84,7 @@ EXPOSE 22 5900 8006 ENV VERSION="14" ENV RAM_SIZE="4G" -ENV CPU_CORES="1" +ENV CPU_CORES="2" ENV DISK_SIZE="64G" ENTRYPOINT ["/usr/bin/tini", "-s", "/run/entry.sh"] diff --git a/assets/config.plist b/assets/config.plist index ee3b944..19b55db 100644 --- a/assets/config.plist +++ b/assets/config.plist @@ -1394,7 +1394,7 @@ ReloadOptionRoms RequestBootVarRouting - + ResizeGpuBars -1 ResizeUsePciRbIo diff --git a/assets/install/package/Distribution b/assets/install/package/Distribution new file mode 100644 index 0000000..23a8472 --- /dev/null +++ b/assets/install/package/Distribution @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + #@COMPONENT@ + + diff --git a/assets/install/package/PackageInfo b/assets/install/package/PackageInfo new file mode 100644 index 0000000..0382920 --- /dev/null +++ b/assets/install/package/PackageInfo @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/install/package/admin.pkg b/assets/install/package/admin.pkg new file mode 100644 index 0000000..d63aa37 Binary files /dev/null and b/assets/install/package/admin.pkg differ diff --git a/assets/install/package/skipsetup.pkg b/assets/install/package/skipsetup.pkg new file mode 100644 index 0000000..d8eef7c Binary files /dev/null and b/assets/install/package/skipsetup.pkg differ diff --git a/assets/install/setup/com.apple.SetupAssistant.plist b/assets/install/setup/com.apple.SetupAssistant.plist new file mode 100644 index 0000000..27553ec --- /dev/null +++ b/assets/install/setup/com.apple.SetupAssistant.plist @@ -0,0 +1,64 @@ + + + + + DidSeeAccessibility + + DidSeeActivationLock + + DidSeeAppStore + + DidSeeAppearanceSetup + + DidSeeApplePaySetup + + DidSeeAvatarSetup + + DidSeeCloudSetup + + DidSeePrivacy + + DidSeeScreenTime + + DidSeeSiriSetup + + DidSeeSyncSetup + + DidSeeSyncSetup2 + + DidSeeTouchIDSetup + + DidSeeTrueTone + + DidSeeiCloudLoginForStorageServices + + LastPreLoginTasksPerformedBuild + @OS_BUILD@ + LastPreLoginTasksPerformedVersion + @OS_VERSION@ + LastPrivacyBundleVersion + 2 + LastSeenBuddyBuildVersion + @OS_BUILD@ + LastSeenCloudProductVersion + @OS_VERSION@ + LastSeenDiagnosticsProductVersion + @OS_VERSION@ + LastSeenSiriProductVersion + @OS_VERSION@ + MiniBuddyLaunchReason + 0 + MiniBuddyLaunchedPostMigration + + MiniBuddyShouldLaunchToResumeSetup + + NSAddServicesToContextMenus + + PreviousBuildVersion + 0 + PreviousSystemVersion + 0 + SkipFirstLoginOptimization + + + diff --git a/docs/environment.md b/docs/environment.md index 1d62c9c..d9641e7 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -19,7 +19,7 @@ An empty default means the variable is unset and its value is determined automat | Variable | Default | Description | |---|---|---| -| `CPU_CORES` | `1` | Number of virtual CPU cores, such as `4`, `half`, or `max`. | +| `CPU_CORES` | `2` | Number of virtual CPU cores, such as `4`, `half`, or `max`. | | `CPU_MODEL` | | QEMU CPU model. Selected automatically for Intel or AMD hosts when unset. | | `CPU_FLAGS` | | Additional QEMU CPU flags. | | `SMP` | | Custom CPU topology. Determined from `CPU_CORES` when unset. | @@ -72,11 +72,10 @@ An empty default means the variable is unset and its value is determined automat | Variable | Default | Description | |---|---|---| | `DISPLAY` | `web` | Display backend, such as `web`, `vnc`, `disabled`, or `none`. | -| `VGA` | `vmware` | QEMU video adapter model. | +| `VGA` | `vmware` | QEMU video adapter model used when `GPU=N`. | | `WIDTH` | `1920` | Display width configured for macOS and OpenCore. | | `HEIGHT` | `1080` | Display height configured for macOS and OpenCore. | -| `GPU` | `N` | Enables experimental GPU acceleration. | -| `RENDERNODE` | `/dev/dri/renderD128` | Render node used for GPU acceleration. | +| `GPU` | `N` | Enables hardware-accelerated graphics through Reims vGPU. | ## 🌍 Web UI diff --git a/readme.md b/readme.md index fa177ae..2c47880 100644 --- a/readme.md +++ b/readme.md @@ -17,10 +17,10 @@ MacOS inside a Docker container. ## Features ✨ - Runs macOS inside a Docker container -- Automatic download of the installation files -- Web-based viewer for controlling the VM +- Automatic download and hands-free installation - Near-native performance with KVM acceleration - Customizable CPU, memory, and storage allocation +- Hardware-accelerated graphics via [Reims vGPU](https://github.com/steelbrain/reims-vgpu) - Dynamic memory allocation with memory ballooning - USB passthrough and host folder sharing - Supports NAT, user-mode, macvlan, and macvtap networking @@ -163,7 +163,7 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste ### How do I change the amount of CPU or RAM? - By default, macOS will be allowed to use a single CPU core and 4 GB of RAM. + By default, macOS will be allowed to use 2 CPU cores and 4 GB of RAM. If you want to adjust this, you can specify the desired amount using the following environment variables: @@ -174,9 +174,7 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste ``` > [!IMPORTANT] -> On AMD systems, avoid assigning multiple CPU cores or more than 8 GB of RAM initially. Depending on the specific AMD CPU model, multiple cores may reduce performance or cause instability, while more than 8 GB of RAM may cause the installation to freeze at the country selection step. Increase the RAM only after installation, and the core count only after macOS has been running reliably for several hours. -> -> Intel processors offer much better macOS compatibility, so multiple cores and more RAM can be assigned from the start without causing these issues. +> On AMD systems, do not assign more than 8 GB of RAM during the initial installation process. ### How do I enable audio? @@ -189,6 +187,36 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste Then enable **Audio** under **Settings → Advanced** in the web viewer. The stream is only active while this option is enabled, so it uses no extra bandwidth otherwise. +### How do I enable GPU acceleration? + +To enable [Reims vGPU](https://github.com/steelbrain/reims-vgpu) hardware-accelerated graphics using an Intel or AMD GPU, add the following lines to your compose file: + +```yaml +environment: + GPU: "Y" +devices: + - /dev/dri +``` + +For NVIDIA GPUs, the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host and the GPU must be exposed to the container: + +```yaml +environment: + GPU: "Y" + NVIDIA_DRIVER_CAPABILITIES: "all" + +deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: + - gpu +``` + +Reims vGPU uses macOS's built-in `AppleParavirtGPU` driver and translates Metal graphics commands to Vulkan on the Linux host, so no additional graphics driver needs to be installed inside macOS. + ### How do I share files with the host? To share files with the host, add the following volume to your compose file: diff --git a/src/boot.sh b/src/boot.sh index 7d12eed..2be8617 100644 --- a/src/boot.sh +++ b/src/boot.sh @@ -137,34 +137,59 @@ addOvmfOptions() { return 0 } +openCoreSource() { + + local flavour="release" + enabled "$OPENCORE_DEBUG" && flavour="debug" + + echo "/opencore/$flavour/EFI" + return 0 +} + extractOpenCore() { - # OpenCoreBoot ISO="/opencore.iso" OUT="/tmp/extract" + local source template + source=$(openCoreSource) + template="$OUT/template/EFI_RELEASE/EFI" + rm -rf "$OUT" - mkdir -p "$OUT" + mkdir -p "$OUT/template" msg="Extracting OpenCore boot image" info "$msg..." && html "$msg..." - # Extract image file + if [ ! -d "$source" ]; then + error "Could not find official OpenCore EFI tree \"$source\"." && exit 10 + fi + if [ ! -s "$ISO" ]; then error "Could not find image file \"$ISO\"." && exit 10 fi - if ! 7z x "$ISO" -o"$OUT" > /dev/null; then + if ! 7z x "$ISO" -o"$OUT/template" > /dev/null; then error "Failed to extract archive!" && exit 11 fi - # The bundled image supplies OpenCore binaries, but its config is replaced - # with the project template containing this VM's generated identity. - # Overwrite extracted OpenCore config with our own - CFG="$(find "$OUT" -type f -path '*/EFI/OC/config.plist' -print -quit)" - [ -z "${CFG:-}" ] && error "Could not locate extracted OpenCore config.plist under \"$OUT\"." && exit 12 + if [ ! -d "$template/OC/ACPI" ] || + [ ! -d "$template/OC/Kexts" ] || + [ ! -d "$template/OC/Resources" ]; then + error "LongQT OpenCore template does not contain the expected EFI_RELEASE tree!" && exit 12 + fi - EFI_DIR="${CFG%/OC/config.plist}" + EFI_DIR="$OUT/EFI" + cp -a "$source" "$EFI_DIR" + + # Never mix old OpenCore executables/drivers with the official tree. + # Only carry over VM-specific data that OpenCore itself does not provide. + rm -rf "$EFI_DIR/OC/ACPI" "$EFI_DIR/OC/Kexts" "$EFI_DIR/OC/Resources" + cp -a "$template/OC/ACPI" "$EFI_DIR/OC/" + cp -a "$template/OC/Kexts" "$EFI_DIR/OC/" + cp -a "$template/OC/Resources" "$EFI_DIR/OC/" + + CFG="$EFI_DIR/OC/config.plist" return 0 } @@ -179,10 +204,6 @@ checkOpenCoreFiles() { error "Missing OpenCore.efi!" && exit 12 fi - if [ ! -s "$EFI_DIR/OC/config.plist" ]; then - error "Missing OpenCore config.plist!" && exit 12 - fi - if [ ! -d "$EFI_DIR/OC/Drivers" ]; then error "Missing OpenCore Drivers directory!" && exit 12 fi @@ -191,6 +212,10 @@ checkOpenCoreFiles() { error "Missing OpenCore Kexts directory!" && exit 12 fi + if [ ! -d "$EFI_DIR/OC/Resources" ]; then + error "Missing OpenCore Resources directory!" && exit 12 + fi + return 0 } @@ -213,6 +238,7 @@ configureOpenCorePlist() { local generic="/plist/dict/key[.='PlatformInfo']/following-sibling::dict[1]/key[.='Generic']/following-sibling::dict[1]" local output="/plist/dict/key[.='UEFI']/following-sibling::dict[1]/key[.='Output']/following-sibling::dict[1]" local boot="/plist/dict/key[.='Misc']/following-sibling::dict[1]/key[.='Boot']/following-sibling::dict[1]" + local debug="/plist/dict/key[.='Misc']/following-sibling::dict[1]/key[.='Debug']/following-sibling::dict[1]" xmlstarlet ed -P -L \ -u "$generic/key[.='ROM']/following-sibling::data[1]" -v "$brom" \ @@ -223,6 +249,15 @@ configureOpenCorePlist() { -u "$output/key[.='Resolution']/following-sibling::string[1]" -v "$resolution" \ "$CFG" + # DEBUG logging goes only to the OpenCore log file on the EFI partition. + # Target 65 = logging (0x01) + file (0x40), deliberately excluding screen. + if enabled "$OPENCORE_DEBUG"; then + xmlstarlet ed -P -L \ + -u "$debug/key[.='DisplayLevel']/following-sibling::integer[1]" -v 2147483714 \ + -u "$debug/key[.='Target']/following-sibling::integer[1]" -v 65 \ + "$CFG" + fi + # Show boot picker if requested # Showing the picker also exposes auxiliary entries and extends the timeout # so recovery and maintenance choices remain selectable. @@ -402,12 +437,14 @@ printMachineDetails() { openCoreSignature() { - local opencore config vmhide + local opencore template config vmhide source local plist="/assets/config.plist" [ -f "/custom.plist" ] && plist="/custom.plist" + source=$(openCoreSource) - opencore=$(sha256sum /opencore.iso | awk '{print $1}') || return 1 + opencore=$(find "$source" -type f -exec sha256sum {} + | sort | sha256sum | awk '{print $1}') || return 1 + template=$(sha256sum /opencore.iso | awk '{print $1}') || return 1 config=$(sha256sum "$plist" | awk '{print $1}') || return 1 vmhide=$(sha256sum /vmh.zip | awk '{print $1}') || return 1 @@ -423,6 +460,8 @@ openCoreSignature() { echo "HEIGHT=$HEIGHT" echo "PICKER=$PICKER" echo "OPENCORE=$opencore" + echo "TEMPLATE=$template" + echo "DEBUG=$OPENCORE_DEBUG" echo "PLIST=$config" echo "VMHIDE=$vmhide" } | sha256sum | awk '{print $1}' @@ -435,6 +474,8 @@ prepareOpenCoreImage() { local target="$STORAGE/boot.img" local current previous signature + OPENCORE_DEBUG="$DEBUG" + signature=$(stateFile "sig" "boot") || exit 11 current=$(openCoreSignature) previous=$(readState "sig" "boot") || exit 11 @@ -522,11 +563,6 @@ prepareOpenCoreImage setOwner "$IMG" || error "Failed to set the owner for \"$IMG\" !" -BOOT_DRIVE_ID="OpenCore" - -# OpenCore is immutable at runtime; persistent boot choices live in OVMF -# NVRAM, so attach the generated boot disk read-only. -DISK_OPTS+=" -device virtio-blk-pci,drive=${BOOT_DRIVE_ID},bus=pcie.0,addr=0x5,bootindex=$BOOT_INDEX" -DISK_OPTS+=" -drive file=$IMG,id=$BOOT_DRIVE_ID,format=raw,cache=unsafe,readonly=on,if=none" +DISK_OPTS+=$(createDevice "$IMG" "blk" "$BOOT_INDEX" "0x5" "raw" "" "" "OpenCore") return 0 diff --git a/src/cpu.sh b/src/cpu.sh index 729b922..5568b8d 100644 --- a/src/cpu.sh +++ b/src/cpu.sh @@ -135,13 +135,7 @@ checkClocksource() { result="${result//[![:print:]]/}" case "${result,,}" in - # A single-vCPU Intel profile is promoted to two when host timing is - # suitable, avoiding macOS issues with the one-core topology. - "${CLOCKSOURCE,,}" ) - if ! needsAmdCpuProfile && [[ "$CPU_CORES" == "1" ]]; then - CPU_CORES="2" - fi - ;; + "${CLOCKSOURCE,,}" ) ;; "kvm-clock" ) warn "Nested KVM virtualization detected, this might cause issues running macOS!" ;; diff --git a/src/display.sh b/src/display.sh new file mode 100644 index 0000000..a2a143d --- /dev/null +++ b/src/display.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Docker environment variables + +: "${GPU:="N"}" # GPU acceleration +: "${VGA:="vmware"}" # VGA adapter +: "${DISPLAY:="web"}" # Display type +: "${LOSSY:="N"}" # Lossy VNC compression +: "${VNC_PORT:="5900"}" # VNC port + +# Sanitize variables +VGA=$(strip "$VGA") +LOSSY=$(strip "$LOSSY") +DISPLAY=$(strip "$DISPLAY") +VNC_PORT=$(strip "$VNC_PORT") +WSS_SOCKET="${WSS_SOCKET:-$QEMU_DIR/vnc-ws.sock}" + +VGA_DEVICE="${VGA%%,*}" +VGA_OPTIONS="${VGA#"$VGA_DEVICE"}" + +case "${VGA_DEVICE,,}" in + "std" | "vga" ) + VGA_DEVICE="VGA" + VGA_ARG="-device" ;; + "vmware" | "vmware-svga" ) + VGA_DEVICE="vmware-svga" + VGA_ARG="-device" ;; + "virtio" ) + VGA_DEVICE="virtio-vga" + VGA_ARG="-device" ;; + "virtio-"* ) + VGA_DEVICE="${VGA_DEVICE,,}" + VGA_ARG="-device" ;; + * ) + VGA_ARG="-vga" ;; +esac + +VGA="${VGA_DEVICE}${VGA_OPTIONS}" +VGA_ARG+=" ${VGA}" + +# QEMU accepts a VNC display number rather than a TCP port, +# so translate the configured port back to its :N display index. +port=$(( VNC_PORT - 5900 )) + +LOSSY_OPT="" +enabled "$LOSSY" && LOSSY_OPT=",lossy=on" + +# Preserve the historic :0 setting as an alias for the managed web display. +[[ "$DISPLAY" == ":0" ]] && DISPLAY="web" + +case "${DISPLAY,,}" in + + "vnc" ) + DISPLAY_OPTS="-display vnc=:${port}${LOSSY_OPT} ${VGA_ARG}" ;; + "web" ) + DISPLAY_OPTS="-display vnc=:${port},websocket=unix:${WSS_SOCKET}${LOSSY_OPT} ${VGA_ARG}" ;; + "disabled" ) + DISPLAY_OPTS="-display none ${VGA_ARG}" ;; + "none" ) + DISPLAY_OPTS="-display none -vga none" ;; + *) + DISPLAY_OPTS="-display ${DISPLAY} ${VGA_ARG}" ;; + +esac + +enabled "$GPU" || return 0 + +msg="Configuring Reims vGPU..." +enabled "$DEBUG" && echo "$msg" + +export METAL2VULKAN_LLVM_DIS="/usr/local/bin/air-dis" + +if [ ! -d /dev/dri ]; then + error "GPU acceleration was requested, but '/dev/dri' was not added to the devices section of your compose file." + exit 72 +fi + +RENDER_NODE="" +for node in /dev/dri/renderD*; do + + [ -c "$node" ] || continue + + gpu_fd="" + if ! { exec {gpu_fd}<>"$node"; } 2>/dev/null; then + continue + fi + + { exec {gpu_fd}>&-; } 2>/dev/null || true + RENDER_NODE="$node" + break +done + +if [ -z "$RENDER_NODE" ]; then + error "GPU acceleration was requested, but no accessible DRM render node was found in '/dev/dri'." + exit 72 +fi + +if ! command -v vulkaninfo >/dev/null 2>&1; then + error "GPU acceleration was requested, but 'vulkaninfo' is not available in the container." + exit 72 +fi + +VULKAN_SUMMARY="" +if ! VULKAN_SUMMARY="$(vulkaninfo --summary 2>&1)"; then + enabled "$DEBUG" && printf '%s\n' "$VULKAN_SUMMARY" + error "GPU acceleration was requested, but Vulkan device enumeration failed." + exit 72 +fi + +# Reims requires Vulkan 1.2 and rejects devices below that API floor. Mirror its +# device policy here: CPU/software Vulkan does not count as hardware rendering, +# while discrete, integrated, virtual and other non-CPU devices are eligible. +if ! awk ' + function check_device() { + if (!in_device || type == "" || major < 0 || minor < 0) { + return + } + + if (type != "PHYSICAL_DEVICE_TYPE_CPU" && + (major > 1 || (major == 1 && minor >= 2))) { + compatible = 1 + } + } + + /^GPU[0-9]+:/ { + check_device() + in_device = 1 + major = -1 + minor = -1 + type = "" + next + } + + in_device && /^[[:space:]]*apiVersion[[:space:]]*=/ { + value = $0 + sub(/^.*=[[:space:]]*/, "", value) + split(value, version, ".") + major = version[1] + 0 + minor = version[2] + 0 + next + } + + in_device && /^[[:space:]]*deviceType[[:space:]]*=/ { + type = $0 + sub(/^.*=[[:space:]]*/, "", type) + sub(/[[:space:]].*$/, "", type) + next + } + + END { + check_device() + exit compatible ? 0 : 1 + } +' <<< "$VULKAN_SUMMARY"; then + enabled "$DEBUG" && printf '%s\n' "$VULKAN_SUMMARY" + error "GPU acceleration was requested, but no Vulkan 1.2+ device is available." + exit 72 +fi + +# Vulkan 1.2 promotes 8-bit storage and viewport-index output into core feature +# structures, but support for both remains optional. Reims enables and relies on +# these capabilities when translating the guest Metal command stream, so verify +# them on the same Vulkan 1.2+ non-CPU device accepted above. +VULKAN_DETAILS="" +if ! VULKAN_DETAILS="$(env -u DISPLAY -u WAYLAND_DISPLAY vulkaninfo 2>&1)"; then + enabled "$DEBUG" && printf '%s\n' "$VULKAN_DETAILS" + error "GPU acceleration was requested, but Vulkan capability enumeration failed." + exit 72 +fi + +if ! awk ' + function check_device() { + if (!in_device || type == "" || major < 0 || minor < 0) { + return + } + + if (type != "PHYSICAL_DEVICE_TYPE_CPU" && + (major > 1 || (major == 1 && minor >= 2)) && + storage8 == "true" && viewport_index == "true") { + compatible = 1 + } + } + + /^GPU[0-9]+:/ { + check_device() + in_device = 1 + major = -1 + minor = -1 + type = "" + storage8 = "" + viewport_index = "" + next + } + + in_device && /^[[:space:]]*apiVersion[[:space:]]*=/ { + value = $0 + sub(/^.*=[[:space:]]*/, "", value) + split(value, version, ".") + major = version[1] + 0 + minor = version[2] + 0 + next + } + + in_device && /^[[:space:]]*deviceType[[:space:]]*=/ { + type = $0 + sub(/^.*=[[:space:]]*/, "", type) + sub(/[[:space:]].*$/, "", type) + next + } + + in_device && /^[[:space:]]*storageBuffer8BitAccess[[:space:]]*=/ { + storage8 = $0 + sub(/^.*=[[:space:]]*/, "", storage8) + sub(/[[:space:]].*$/, "", storage8) + next + } + + in_device && /^[[:space:]]*shaderOutputViewportIndex[[:space:]]*=/ { + viewport_index = $0 + sub(/^.*=[[:space:]]*/, "", viewport_index) + sub(/[[:space:]].*$/, "", viewport_index) + next + } + + END { + check_device() + exit compatible ? 0 : 1 + } +' <<< "$VULKAN_DETAILS"; then + enabled "$DEBUG" && printf '%s\n' "$VULKAN_DETAILS" + error "GPU acceleration was requested, but no Vulkan 1.2+ device provides Reims' required storageBuffer8BitAccess and shaderOutputViewportIndex features." + exit 72 +fi + +REIMS_ROM="/usr/share/qemu/reims-vgpu-gop.rom" +if [ ! -s "$REIMS_ROM" ]; then + error "Reims GOP ROM is missing at '$REIMS_ROM'." + exit 72 +fi + +# Reims maps guest RAM into its Vulkan backend, so the memory must be backed by +# a shared memfd. config.sh is sourced later and turns this into the matching +# memory-backend-memfd object plus machine memory-backend property. +RAM_BACKEND="memfd" + +# The upstream x86 product path keeps Reims behind a secondary conventional PCI +# bridge. The GOP ROM belongs to this same device; it is not a second display. +REIMS_OPTS="-vga none" +REIMS_OPTS+=" -device pci-bridge,chassis_nr=5,id=pci.5,bus=pcie.0,addr=1e.0" +REIMS_OPTS+=" -device reims-vgpu-pci,id=reimsvgpu,romfile=${REIMS_ROM},rombar=1,bus=pci.5,addr=01.0" + +case "${DISPLAY,,}" in + + "vnc" ) + DISPLAY_OPTS="-display none ${REIMS_OPTS} -vnc :${port}${LOSSY_OPT}" ;; + "web" ) + DISPLAY_OPTS="-display none ${REIMS_OPTS} -vnc :${port},websocket=unix:${WSS_SOCKET}${LOSSY_OPT}" ;; + "disabled" | "none" ) + DISPLAY_OPTS="-display none ${REIMS_OPTS}" ;; + * ) + DISPLAY_OPTS="-display ${DISPLAY} ${REIMS_OPTS}" ;; + +esac + +echo +info "Hardware rendering enabled successfully. Beware that this feature is still experimental!" + +enabled "$DEBUG" && echo && printf '%s\n' "$VULKAN_DETAILS" + +return 0 diff --git a/src/entry.sh b/src/entry.sh index e3d0a36..55db0b3 100644 --- a/src/entry.sh +++ b/src/entry.sh @@ -22,7 +22,8 @@ cd /run . memory.sh # Check available memory . server.sh # Start webserver . download.sh # Load functions -. install.sh # Get the OSX images +. image.sh # Prepare image +. install.sh # Run installation . disk.sh # Initialize disks . display.sh # Initialize graphics . audio.sh # Initialize audio diff --git a/src/image.sh b/src/image.sh new file mode 100644 index 0000000..ff1e548 --- /dev/null +++ b/src/image.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +IMAGE_TOOLS="/run/install" +IMAGE_ASSETS="/assets/install" + +checkDmgImage() { + + local file="$1" + local size + + if [ ! -s "$file" ]; then + error "Downloaded recovery image is missing or empty!" + return 1 + fi + + size=$(stat -c%s "$file") + + if [ "$size" -lt 100000000 ]; then + error "Downloaded recovery image is too small: $(formatBytes "$size")" + return 1 + fi + + info "Checking recovery image format..." + + # qemu-img validates the disk container structure without mounting or + # trusting filesystems inside the downloaded recovery image. + if ! qemu-img info "$file" >/dev/null; then + error "Downloaded recovery image is not a valid disk image!" + return 1 + fi + + return 0 +} + +buildProductPackage() { + + local identifier="$1" + local scripts="$2" + local dest="$3" + local work source_file + local outer_listing scripts_listing + + work=$(mktemp -d "$STORAGE/tmp/package.XXXXXX") || return 1 + + local component_name="component.pkg" + local component="$work/$component_name" + local verify="$work/verify" + + mkdir -p "$component" "$verify" + + # Match the payload-free component layout produced by Linux macOS package + # generators: PackageInfo and Scripts live below component.pkg in the same + # outer XAR as Distribution. component.pkg is not a nested XAR. + if ! /usr/bin/sed \ + -e "s|@IDENTIFIER@|$identifier|g" \ + "$IMAGE_ASSETS/package/PackageInfo" > "$component/PackageInfo"; then + rm -rf "$work" + error "Failed to prepare PackageInfo for $identifier." + return 1 + fi + + if ! ( + cd "$scripts" + find . -print | + cpio -o --format odc --owner 0:80 2>/dev/null | + gzip -c > "$component/Scripts" + ); then + rm -rf "$work" + error "Failed to create scripts archive for $identifier." + return 1 + fi + + if ! /usr/bin/sed \ + -e "s|@IDENTIFIER@|$identifier|g" \ + -e "s|@COMPONENT@|$component_name|g" \ + "$IMAGE_ASSETS/package/Distribution" > "$work/Distribution"; then + rm -rf "$work" + error "Failed to prepare Distribution for $identifier." + return 1 + fi + + rm -f "$dest" + + if ! ( + cd "$work" + xar --compression none -cf "$dest" Distribution "$component_name" + ); then + rm -rf "$work" + error "Failed to build product package $dest." + return 1 + fi + + if ! outer_listing=$(xar -tf "$dest" 2>/dev/null); then + rm -rf "$work" "$dest" + error "Failed to inspect product package $dest." + return 1 + fi + + for name in \ + Distribution \ + "$component_name/PackageInfo" \ + "$component_name/Scripts"; do + + if ! grep -Fxq "$name" <<< "$outer_listing"; then + rm -rf "$work" "$dest" + error "Product package $dest is missing $name." + return 1 + fi + done + + if ! ( + cd "$verify" + xar -xf "$dest" + ); then + rm -rf "$work" "$dest" + error "Failed to extract product package $dest for verification." + return 1 + fi + + if ! python3 "$IMAGE_TOOLS/package/validate.py" \ + "$verify/Distribution" \ + "$verify/$component_name/PackageInfo" \ + "$identifier"; then + rm -rf "$work" "$dest" + error "Product package $dest failed XML validation." + return 1 + fi + + if ! scripts_listing=$( + gzip -dc "$verify/$component_name/Scripts" 2>/dev/null | + cpio -it 2>/dev/null + ); then + rm -rf "$work" "$dest" + error "Failed to inspect the scripts archive in $dest." + return 1 + fi + + if ! grep -Fxq './postinstall' <<< "$scripts_listing"; then + rm -rf "$work" "$dest" + error "Product package $dest does not contain its postinstall script." + return 1 + fi + + mkdir -p "$verify/scripts" + + if ! ( + cd "$verify/scripts" + gzip -dc "$verify/$component_name/Scripts" 2>/dev/null | + cpio -idmu 2>/dev/null + ); then + rm -rf "$work" "$dest" + error "Failed to extract the scripts archive in $dest." + return 1 + fi + + for source_file in "$scripts"/*; do + + [ -f "$source_file" ] || continue + + local name="${source_file##*/}" + local packaged_file="$verify/scripts/$name" + + if [ ! -f "$packaged_file" ] || ! cmp -s "$source_file" "$packaged_file"; then + rm -rf "$work" "$dest" + error "Packaged script data $name failed byte-for-byte validation." + return 1 + fi + + local source_mode packaged_mode + source_mode=$(stat -c '%a' "$source_file") + packaged_mode=$(stat -c '%a' "$packaged_file") + + if [ "$source_mode" != "$packaged_mode" ]; then + rm -rf "$work" "$dest" + error "Packaged script data $name has mode $packaged_mode instead of $source_mode." + return 1 + fi + + done + + rm -rf "$work" + return 0 +} + +createAdminPackage() { + + local dest="$1" + + local msg="Building unattended setup packages..." + info "$msg" + + local work + work=$(mktemp -d "$STORAGE/tmp/admin.XXXXXX") || return 1 + + local scripts="$work/scripts" + mkdir -p "$scripts" + + if ! python3 "$IMAGE_TOOLS/admin/create.py" \ + "$scripts" \ + "$SETUP_USERNAME" \ + "$SETUP_PASSWORD" \ + "$SETUP_AUTOLOGIN"; then + rm -rf "$work" + error "Failed to create unattended account data." + return 1 + fi + + if ! cp -f "$IMAGE_TOOLS/admin/postinstall" "$scripts/postinstall"; then + rm -rf "$work" + error "Failed to prepare unattended account package script." + return 1 + fi + + chmod 0755 "$scripts/postinstall" "$scripts/config" + chmod 0600 "$scripts/user.plist" + [ ! -f "$scripts/kcpassword" ] || chmod 0600 "$scripts/kcpassword" + + if ! bash -n "$scripts/postinstall"; then + rm -rf "$work" + error "Generated account package script is invalid." + return 1 + fi + + if ! python3 "$IMAGE_TOOLS/admin/validate.py" \ + "$scripts/user.plist" "$SETUP_USERNAME"; then + rm -rf "$work" + error "Generated account record failed validation." + return 1 + fi + + if ! buildProductPackage "com.macos.install.admin" "$scripts" "$dest"; then + rm -rf "$work" + return 1 + fi + + rm -rf "$work" + return 0 +} + +createSkipSetupPackage() { + + local dest="$1" + local work scripts + + work=$(mktemp -d "$STORAGE/tmp/skipsetup.XXXXXX") || return 1 + scripts="$work/scripts" + mkdir -p "$scripts" + + if ! cp -f "$IMAGE_TOOLS/setup/postinstall" "$scripts/postinstall" || + ! cp -f "$IMAGE_ASSETS/setup/com.apple.SetupAssistant.plist" \ + "$scripts/com.apple.SetupAssistant.plist"; then + rm -rf "$work" + error "Failed to prepare Setup Assistant package scripts." + return 1 + fi + + chmod 0755 "$scripts/postinstall" + chmod 0644 "$scripts/com.apple.SetupAssistant.plist" + + if ! bash -n "$scripts/postinstall"; then + rm -rf "$work" + error "Generated Setup Assistant package script is invalid." + return 1 + fi + + if ! buildProductPackage "com.macos.install.skipsetup" "$scripts" "$dest"; then + rm -rf "$work" + return 1 + fi + + rm -rf "$work" + return 0 +} + +createAutomatedInstallationFiles() { + + local script="$1" + + if ! cp -f "$IMAGE_TOOLS/recovery/launch.sh" "$script"; then + error "Failed to prepare automated Recovery files." + return 1 + fi + + chmod 0755 "$script" + + return 0 +} + +prepareInstallationState() { + + local state="$1" + local script="$state/launch.sh" + local admin="$state/admin.pkg" + local setup="$state/skipsetup.pkg" + local firstboot="$state/firstboot.sh" + local daemon="$state/com.dockur.macos.firstboot.plist" + + rm -rf "$state" + + if ! makeDir "$state"; then + error "Failed to create unattended installation state directory." + return 1 + fi + + if ! createAutomatedInstallationFiles "$script" || + ! cp -f "$IMAGE_ASSETS/package/admin.pkg" "$admin" || + ! cp -f "$IMAGE_ASSETS/package/skipsetup.pkg" "$setup" || + ! cp -f "$IMAGE_TOOLS/firstboot/launch.sh" "$firstboot" || + ! cp -f "$IMAGE_TOOLS/firstboot/com.dockur.macos.firstboot.plist" "$daemon"; then + rm -rf "$state" + return 1 + fi + + chmod 0755 "$script" "$firstboot" + chmod 0644 "$admin" "$setup" "$daemon" + + if ! cmp -s "$IMAGE_TOOLS/recovery/launch.sh" "$script" || + ! cmp -s "$IMAGE_ASSETS/package/admin.pkg" "$admin" || + ! cmp -s "$IMAGE_ASSETS/package/skipsetup.pkg" "$setup" || + ! cmp -s "$IMAGE_TOOLS/firstboot/launch.sh" "$firstboot" || + ! cmp -s "$IMAGE_TOOLS/firstboot/com.dockur.macos.firstboot.plist" "$daemon" || + [ ! -s "$admin" ] || [ ! -s "$setup" ]; then + rm -rf "$state" + error "Staged unattended installation files failed validation." + return 1 + fi + + if ! bash -n "$firstboot"; then + rm -rf "$state" + error "First-boot account setup script is invalid." + return 1 + fi + + if ! python3 -c 'import plistlib, sys; plistlib.load(open(sys.argv[1], "rb"))' "$daemon"; then + rm -rf "$state" + error "First-boot LaunchDaemon plist is invalid." + return 1 + fi + + return 0 +} + +patchRecoveryBootstrap() { + + local image="$1" + local result + + if ! result=$(python3 "$IMAGE_TOOLS/recovery/patch.py" "$image"); then + error "Failed to patch the Recovery startup hook." + return 1 + fi + + info "$result" + return 0 +} + +prepareAutomatedRecovery() { + + local source="$1" + local dest="$2" + local qemu_info + local msg="Preparing automated installation..." + + info "$msg" && html "$msg" + + if ! patchRecoveryBootstrap "$source"; then + return 1 + fi + + # The source keeps its .dmg suffix while being patched, so QEMU can probe + # the exact final bytes using its DMG driver before the file is moved. + if ! qemu_info=$(qemu-img info --output=json "$source" 2>/dev/null); then + error "Patched recovery image is not recognized by QEMU." + return 1 + fi + + if ! python3 -c ' +import json, sys +info = json.load(sys.stdin) +raise SystemExit(0 if info.get("format") == "dmg" else 1) +' <<< "$qemu_info"; then + error "QEMU did not identify the patched recovery image as DMG." + return 1 + fi + + if ! mv -f "$source" "$dest"; then + error "Failed to save automated recovery image to $dest." + return 1 + fi + + return 0 +} + +return 0 diff --git a/src/install.sh b/src/install.sh index 6577e04..8bc00f1 100644 --- a/src/install.sh +++ b/src/install.sh @@ -11,6 +11,7 @@ set -Eeuo pipefail : "${WIDTH:="1920"}" # Horizontal : "${HEIGHT:="1080"}" # Vertical : "${MODEL:="iMacPro1,1"}" # Device model +: "${MANUAL:="Y"}" # Manual installation # Sanitize variables SN=$(strip "$SN") @@ -21,8 +22,18 @@ MODEL=$(strip "$MODEL") WIDTH=$(strip "$WIDTH") HEIGHT=$(strip "$HEIGHT") -BASE_IMG_ID="InstallMedia" -BASE_IMG="$STORAGE/base.dmg" +BASE_IMG="$STORAGE/setup.dmg" + +# Fixed setup values for the unattended-install proof. These are intentionally +# not environment variables yet; locale settings will be implemented only +# after the basic zero-click path has been proven. +SETUP_USERNAME="admin" +SETUP_PASSWORD="admin" +SETUP_AUTOLOGIN="Y" +SETUP_LANGUAGE="en-US" +SETUP_REGION="US" +SETUP_KEYBOARD="U.S." +SETUP_TIMEZONE="Etc/UTC" function getRandom() { @@ -195,82 +206,13 @@ function download() { return 0 } -checkDmgImage() { - - local file="$1" - local size - - if [ ! -s "$file" ]; then - error "Downloaded recovery image is missing or empty!" - return 1 - fi - - size=$(stat -c%s "$file") - - if [ "$size" -lt 100000000 ]; then - error "Downloaded recovery image is too small: $(formatBytes "$size")" - return 1 - fi - - info "Checking recovery image format..." - - # qemu-img validates the disk container structure without mounting or - # trusting filesystems inside the downloaded recovery image. - if ! qemu-img info "$file" >/dev/null; then - error "Downloaded recovery image is not a valid disk image!" - return 1 - fi - - return 0 -} - -checkBootableDmgImage() { - - local file="$1" - local listing - - if ! listing=$(mktemp); then - error "Failed to create temporary file for custom image inspection." - return 1 - fi - - if ! 7z l -slt "$file" > "$listing" 2>/dev/null; then - rm -f "$listing" - error "Failed to inspect the contents of the custom recovery image." - return 1 - fi - - # A directly bootable macOS or recovery image must expose boot.efi. - if grep -Eiq \ - '^Path = (.+[\\/])?(System[\\/]Library[\\/]CoreServices[\\/]boot\.efi|com\.apple\.recovery\.boot[\\/]boot\.efi)$' \ - "$listing"; then - - rm -f "$listing" - return 0 - fi - - # These files identify installer distribution media rather than an image - # that OpenCore can boot directly. - if grep -Eiq \ - '^Path = (.+[\\/])?(InstallAssistant\.pkg|SharedSupport\.dmg|BaseSystem\.dmg)$|^Path = .*Install macOS .*\.app([\\/]|$)' \ - "$listing"; then - - rm -f "$listing" - error "The custom DMG contains macOS installer files, but is not itself a bootable recovery image." - error "Provide the bootable BaseSystem.dmg or RecoveryImage.dmg as /boot.dmg." - return 1 - fi - - rm -f "$listing" - error "The custom DMG is a valid disk image, but no macOS boot loader was found." - return 1 -} - install() { local version="$1" local dest="$2" + local file="$STORAGE/tmp/recovery.dmg" + # Apple recovery catalogs are selected by board identifier, so each macOS # generation maps to a model known to receive that release. case "${version,,}" in @@ -293,38 +235,25 @@ install() { return 1 ;; esac - rm -f "$dest" + rm -f "$dest" "$dest.tmp" if ! makeDir "$STORAGE"; then - error "Failed to create directory \"$STORAGE\" !" && return 1 + error "Failed to create directory \"$STORAGE\" !" + return 1 + fi + + if ! makeDir "$STORAGE/tmp"; then + error "Failed to create directory \"$STORAGE/tmp\" !" + return 1 fi # New recovery media invalidates cached firmware state that may still point # at an older installer or incompatible boot entry. find "$STORAGE" -maxdepth 1 -type f \( -iname '*.rom' -or -iname '*.vars' \) -delete - # A bundled recovery image takes precedence over network retrieval, - # but is subjected to the same size and container-format validation. - if [ -f "/boot.dmg" ]; then + rm -f -- "$file" "$file.aria2" - info "Using custom macOS recovery image from /boot.dmg..." - - if ! cp "/boot.dmg" "$dest"; then - error "Failed to copy custom recovery image to $dest." - return 1 - fi - - if ! checkDmgImage "$dest" || ! checkBootableDmgImage "$dest"; then - rm -f "$dest" - return 1 - fi - - return 0 - fi - - local file="$STORAGE/boot.dmg" - - # Try a multi-connection download first. + # Try a multi-connection recovery download first. if download "$file" "$board" "$version" "${CONNECTIONS:-1}"; then local rc=0 else @@ -350,11 +279,23 @@ install() { fi - if ! mv -f "$file" "$dest"; then - error "Failed to move recovery image to $dest." - return 1 + if enabled "$MANUAL"; then + + if ! mv -f "$file" "$dest"; then + error "Failed to save recovery image to $dest." + return 1 + fi + + else + + if ! prepareAutomatedRecovery "$file" "$dest"; then + rm -f -- "$file" "$file.aria2" + return 1 + fi + fi + rm -f -- "$file" "$file.aria2" return 0 } @@ -438,7 +379,7 @@ fi # overwriting existing disks or redownloading the installation media again. if [ ! -s "$BASE_IMG" ] && ! hasDisk; then STORAGE="$STORAGE/${VERSION,,}" - BASE_IMG="$STORAGE/base.dmg" + BASE_IMG="$STORAGE/setup.dmg" fi # Recovery media is required only while the primary disk is absent or blank. @@ -459,13 +400,36 @@ if ! generateAddress; then error "Failed to generate MAC address!" && exit 37 fi +INSTALL_STATE_DIR="$QEMU_DIR/installstate" +rm -rf "$INSTALL_STATE_DIR" + +if [ -s "$BASE_IMG" ] && ! enabled "$MANUAL"; then + + if ! makeDir "$STORAGE/tmp"; then + error "Failed to create temporary installation directory." + exit 34 + fi + + if ! prepareInstallationState "$INSTALL_STATE_DIR"; then + exit 34 + fi + +fi + +# All installation preparation is complete at this point. Nothing below needs +# persistent scratch data, so never carry version-specific tmp files into QEMU. +if ! rm -rf "$STORAGE/tmp"; then + error "Failed to remove temporary installation files." + exit 34 +fi + DISK_OPTS="" -# Recovery media is attached read-only and only while present; installation -# writes belong on the separately managed primary data disk. -if [ -s "$BASE_IMG" ]; then - DISK_OPTS="-device virtio-blk-pci,drive=${BASE_IMG_ID},bus=pcie.0,addr=0x6" - DISK_OPTS+=" -drive file=$BASE_IMG,id=$BASE_IMG_ID,format=dmg,cache=unsafe,readonly=on,if=none" +# OpenCore uses PCI 0x5. The generic disk layer attaches setup.dmg at 0x6, +# while the state/log share is pinned at 0x7 and managed disks use 0xA-0xF. +if [ -s "$BASE_IMG" ] && ! enabled "$MANUAL"; then + DISK_OPTS="-fsdev local,id=installstatefs,path=$INSTALL_STATE_DIR,security_model=none" + DISK_OPTS+=" -device virtio-9p-pci,id=installstate9p,fsdev=installstatefs,mount_tag=installstate,bus=pcie.0,addr=0x7" fi return 0 diff --git a/src/install/admin/create.py b/src/install/admin/create.py new file mode 100644 index 0000000..521a9bb --- /dev/null +++ b/src/install/admin/create.py @@ -0,0 +1,73 @@ +import hashlib +import os +import plistlib +import secrets +import shlex +import sys +import uuid + +out, username, password, autologin = sys.argv[1:] +generated_uid = str(uuid.uuid4()).upper() + +iterations = 30000 + secrets.randbelow(20000) +salt = secrets.token_bytes(32) +entropy = hashlib.pbkdf2_hmac( + "sha512", password.encode("utf-8"), salt, iterations, dklen=128 +) + +shadow = { + "SALTED-SHA512-PBKDF2": { + "entropy": entropy, + "iterations": iterations, + "salt": salt, + } +} +shadow_data = plistlib.dumps(shadow, fmt=plistlib.FMT_BINARY) + +writers = [ + "_writers_hint", + "_writers_jpegphoto", + "_writers_passwd", + "_writers_picture", + "_writers_realname", + "_writers_UserCertificate", +] + +user = { + "name": [username], + "uid": ["501"], + "gid": ["20"], + "home": [f"/Users/{username}"], + "realname": [username], + "shell": ["/bin/bash"], + "generateduid": [generated_uid], + "passwd": ["********"], + "authentication_authority": [ + ";ShadowHash;HASHLIST:" + ], + "ShadowHashData": [shadow_data], +} +for key in writers: + user[key] = [username] + +with open(os.path.join(out, "user.plist"), "wb") as handle: + plistlib.dump(user, handle, fmt=plistlib.FMT_XML, sort_keys=False) + +with open(os.path.join(out, "config"), "w", encoding="utf-8") as handle: + handle.write("USERNAME=" + shlex.quote(username) + "\n") + handle.write("UUID=" + shlex.quote(generated_uid) + "\n") + handle.write("AUTOLOGIN=" + shlex.quote(autologin) + "\n") + +if autologin.lower() in ("1", "true", "yes", "y", "on"): + key = [125, 137, 82, 35, 210, 188, 221, 234, 163, 185, 31] + data = [ord(char) for char in password] + [0] + remainder = len(data) % 12 + if remainder: + data.extend([0] * (12 - remainder)) + for offset in range(0, len(data), len(key)): + for index in range(offset, min(offset + len(key), len(data))): + data[index] ^= key[index - offset] + if not data: + data = [125] + [0] * 11 + with open(os.path.join(out, "kcpassword"), "wb") as handle: + handle.write(bytes(data)) diff --git a/src/install/admin/postinstall b/src/install/admin/postinstall new file mode 100644 index 0000000..1e9c07b --- /dev/null +++ b/src/install/admin/postinstall @@ -0,0 +1,114 @@ +#!/bin/bash +set -u + +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH + +MYDIR="${0%/*}" +. "$MYDIR/config" + +TARGET="${3:-/}" +if [ "$TARGET" = "/" ]; then + PREFIX="" +else + PREFIX="${TARGET%/}" +fi + +USER_DIR="$PREFIX/private/var/db/dslocal/nodes/Default/users" +ADMIN_PLIST="$PREFIX/private/var/db/dslocal/nodes/Default/groups/admin.plist" +LOGIN_PLIST="$PREFIX/Library/Preferences/com.apple.loginwindow" +PLISTBUDDY="/usr/libexec/PlistBuddy" +STATE_DIR="/Volumes/installstate" +LOCAL_LOG="$PREFIX/private/var/log/macos-unattended-packages.log" + +/bin/mkdir -p "${LOCAL_LOG%/*}" 2>/dev/null || : + +mount_state_share() { + + [ -d "$STATE_DIR" ] && return 0 + [ -x /sbin/mount_9p ] || return 1 + + /sbin/mount_9p installstate >/dev/null 2>&1 || : + [ -d "$STATE_DIR" ] +} + +log() { + + local message="[admin] $1" + + printf '%s\n' "$message" >> "$LOCAL_LOG" 2>/dev/null || : + + if mount_state_share; then + printf '%s\n' "$message" >> "$STATE_DIR/packages.log" 2>/dev/null || : + fi +} + +fail() { + log "ERROR: $1" + echo "account package: $1" >&2 + exit 1 +} + +log "postinstall started (target=$TARGET)" + +plist_array_add() { + + local plist="$1" + local array="$2" + local value="$3" + local current + + current=$("$PLISTBUDDY" -c "Print :$array" "$plist" 2>/dev/null || :) + + if [ -z "$current" ]; then + "$PLISTBUDDY" -c "Add :$array array" "$plist" >/dev/null || + fail "failed to create $array in $plist" + elif printf '%s\n' "$current" | /usr/bin/awk -v wanted="$value" ' + { + line=$0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == wanted) found=1 + } + END { exit(found ? 0 : 1) } + '; then + return 0 + fi + + "$PLISTBUDDY" -c "Add :$array: string \"$value\"" "$plist" >/dev/null || + fail "failed to add $value to $array" +} + +[ -d "$USER_DIR" ] || fail "local user database is missing" +[ -f "$ADMIN_PLIST" ] || fail "admin group database is missing" +[ -f "$MYDIR/user.plist" ] || fail "user plist is missing" + +/bin/cp "$MYDIR/user.plist" "$USER_DIR/$USERNAME.plist" || + fail "failed to install local user record" +/usr/sbin/chown 0:0 "$USER_DIR/$USERNAME.plist" || + fail "failed to set local user ownership" +/bin/chmod 0600 "$USER_DIR/$USERNAME.plist" || + fail "failed to set local user permissions" + +plist_array_add "$ADMIN_PLIST" users "$USERNAME" +plist_array_add "$ADMIN_PLIST" groupmembers "$UUID" +log "administrator account record installed" + +if [ "$AUTOLOGIN" = "Y" ]; then + [ -f "$MYDIR/kcpassword" ] || fail "kcpassword is missing" + /bin/mkdir -p "$PREFIX/private/etc" || + fail "failed to create private/etc" + /bin/cp "$MYDIR/kcpassword" "$PREFIX/private/etc/kcpassword" || + fail "failed to install kcpassword" + /usr/sbin/chown 0:0 "$PREFIX/private/etc/kcpassword" || + fail "failed to set kcpassword ownership" + /bin/chmod 0600 "$PREFIX/private/etc/kcpassword" || + fail "failed to set kcpassword permissions" + + /usr/bin/defaults write "$LOGIN_PLIST" autoLoginUser "$USERNAME" || + fail "failed to configure automatic login" + log "automatic login configured" +fi + +log "postinstall completed" +exit 0 diff --git a/src/install/admin/validate.py b/src/install/admin/validate.py new file mode 100644 index 0000000..b09fd42 --- /dev/null +++ b/src/install/admin/validate.py @@ -0,0 +1,20 @@ +import plistlib +import sys + +path, expected = sys.argv[1:] +with open(path, "rb") as handle: + user = plistlib.load(handle) + +assert user["name"] == [expected] +assert user["uid"] == ["501"] +assert user["gid"] == ["20"] +assert user["realname"] == [expected] +assert user["authentication_authority"] == [ + ";ShadowHash;HASHLIST:" +] + +shadow = plistlib.loads(user["ShadowHashData"][0]) +record = shadow["SALTED-SHA512-PBKDF2"] +assert 30000 <= record["iterations"] < 50000 +assert len(record["salt"]) == 32 +assert len(record["entropy"]) == 128 diff --git a/src/install/firstboot/com.dockur.macos.firstboot.plist b/src/install/firstboot/com.dockur.macos.firstboot.plist new file mode 100644 index 0000000..a57e27e --- /dev/null +++ b/src/install/firstboot/com.dockur.macos.firstboot.plist @@ -0,0 +1,14 @@ + + + + + Label + com.dockur.macos.firstboot + ProgramArguments + + /Library/Application Support/macos-unattended/firstboot.sh + + RunAtLoad + + + diff --git a/src/install/firstboot/launch.sh b/src/install/firstboot/launch.sh new file mode 100644 index 0000000..56a973b --- /dev/null +++ b/src/install/firstboot/launch.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -u + +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH + +LABEL="com.dockur.macos.firstboot" +BASE="/Library/Application Support/macos-unattended" +ADMIN_PACKAGE="$BASE/admin.pkg" +DAEMON="/Library/LaunchDaemons/$LABEL.plist" +USER_DIR="/private/var/db/dslocal/nodes/Default/users" +ADMIN_PLIST="/private/var/db/dslocal/nodes/Default/groups/admin.plist" +LOG="/var/log/macos-unattended-firstboot.log" + +log() { + printf '%s\n' "[firstboot] $1" >> "$LOG" 2>/dev/null || : +} + +fail() { + log "ERROR: $1" + exit 1 +} + +log "first-boot account setup started" + +[ -s "$ADMIN_PACKAGE" ] || fail "prebuilt account package is missing" +[ -x /usr/sbin/installer ] || fail "installer is not available" + +count=0 +while (( count < 300 )); do + if [ -d "$USER_DIR" ] && [ -f "$ADMIN_PLIST" ]; then + break + fi + count=$((count + 1)) + sleep 1 +done + +[ -d "$USER_DIR" ] || fail "local user database did not become available" +[ -f "$ADMIN_PLIST" ] || fail "admin group database did not become available" + +log "local user database is available; installing prebuilt account package" + +if ! /usr/sbin/installer -pkg "$ADMIN_PACKAGE" -target / -verboseR >> "$LOG" 2>&1; then + fail "prebuilt account package installation failed" +fi + +log "prebuilt account package installed successfully" + +if ! /bin/rm -f "$DAEMON"; then + fail "failed to remove first-boot LaunchDaemon" +fi + +if ! /bin/rm -rf "$BASE"; then + fail "failed to remove first-boot staging directory" +fi + +log "first-boot account setup completed" +exit 0 diff --git a/src/install/package/validate.py b/src/install/package/validate.py new file mode 100644 index 0000000..3cccb9f --- /dev/null +++ b/src/install/package/validate.py @@ -0,0 +1,90 @@ +import sys +import xml.etree.ElementTree as ET + + +distribution, package_info, expected = sys.argv[1:] + +droot = ET.parse(distribution).getroot() +if droot.tag != "installer-gui-script": + raise SystemExit("Distribution root element is incorrect") + +products = droot.findall("product") +if ( + len(products) != 1 + or products[0].get("id") != expected + or products[0].get("version") != "1.0" +): + raise SystemExit("Distribution product metadata is missing or incorrect") + +options = droot.findall("options") +if ( + len(options) != 1 + or options[0].get("customize") != "never" + or options[0].get("require-scripts") != "false" +): + raise SystemExit("Distribution options are missing or incorrect") + +refs = [ + element + for element in droot.findall("pkg-ref") + if (element.text or "").strip() == "#component.pkg" +] +if len(refs) != 1 or refs[0].get("id") != expected: + raise SystemExit("Distribution component reference is missing or incorrect") + +ref = refs[0] +if ( + ref.get("version") != "1.0" + or ref.get("installKBytes") != "0" + or ref.get("onConclusion") != "none" +): + raise SystemExit("Distribution component metadata is missing or incorrect") + +metadata_refs = [ + element + for element in droot.findall("pkg-ref") + if element.find("bundle-version") is not None +] +if len(metadata_refs) != 1 or metadata_refs[0].get("id") != expected: + raise SystemExit("Distribution bundle metadata reference is missing") + +choices = [ + element + for element in droot.findall("choice") + if element.get("id") == expected +] +if len(choices) != 1 or choices[0].get("visible") != "false": + raise SystemExit("Distribution package choice is missing or incorrect") + +choice_refs = choices[0].findall("pkg-ref") +if len(choice_refs) != 1 or choice_refs[0].get("id") != expected: + raise SystemExit("Distribution package choice reference is missing or incorrect") + +proot = ET.parse(package_info).getroot() +if proot.tag != "pkg-info": + raise SystemExit("PackageInfo root element is incorrect") + +if ( + proot.get("identifier") != expected + or proot.get("version") != "1.0" + or proot.get("format-version") != "2" + or proot.get("install-location") != "/" + or proot.get("auth") != "root" +): + raise SystemExit("PackageInfo metadata is missing or incorrect") + +payloads = proot.findall("payload") +if ( + len(payloads) != 1 + or payloads[0].get("installKBytes") != "0" + or payloads[0].get("numberOfFiles") != "0" +): + raise SystemExit("PackageInfo no-payload metadata is missing or incorrect") + +scripts = proot.findall("scripts") +if len(scripts) != 1: + raise SystemExit("PackageInfo scripts metadata is missing or duplicated") + +post = scripts[0].findall("postinstall") +if len(post) != 1 or post[0].get("file") != "./postinstall": + raise SystemExit("PackageInfo postinstall entry is missing or incorrect") diff --git a/src/install/recovery/launch.sh b/src/install/recovery/launch.sh new file mode 100644 index 0000000..6ca81ce --- /dev/null +++ b/src/install/recovery/launch.sh @@ -0,0 +1,395 @@ +#!/bin/bash +set -u + +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH + +LOCAL_LOG="/var/log/launch.log" +STATE_DIR="/Volumes/installstate" +STATE_LOG="$STATE_DIR/install.log" +APPLE_INSTALL_LOG="$STATE_DIR/apple.log" +STATE_LOG_LIMIT=$((1 * 1024 * 1024)) +APPLE_INSTALL_LOG_LIMIT=$((4 * 1024 * 1024)) +STARTED="$STATE_DIR/started" +TARGET_VOLUME="/Volumes/Macintosh HD" +ADMIN_PACKAGE="$STATE_DIR/admin.pkg" +SETUP_PACKAGE="$STATE_DIR/skipsetup.pkg" +FIRSTBOOT_SCRIPT="$STATE_DIR/firstboot.sh" +FIRSTBOOT_PLIST="$STATE_DIR/com.dockur.macos.firstboot.plist" +MIN_TARGET_SIZE=$((16 * 1024 * 1024 * 1024)) + +BOOTSTRAP_READY="$STATE_DIR/bootstrap.ready" +BOOTSTRAP_DONE="$STATE_DIR/bootstrap.done" +BOOTSTRAP_FAILED="$STATE_DIR/bootstrap.failed" + +exec >> "$LOCAL_LOG" 2>&1 + +echo "[log] unattended installation starting" + +mount_state_share() { + + local count=0 + + while (( count < 120 )); do + + if [ -d "$STATE_DIR" ]; then + return 0 + fi + + /sbin/mount_9p installstate >/dev/null 2>&1 || : + + [ -d "$STATE_DIR" ] && return 0 + + count=$((count + 1)) + sleep 1 + done + + return 1 +} + +snapshot_log() { + + local source="$1" + local dest="$2" + local limit="$3" + + [ -f "$source" ] || return 0 + /usr/bin/tail -c "$limit" "$source" > "$dest" 2>/dev/null || : + return 0 +} + +mirror_log() { + + local source="$1" + local dest="$2" + local limit="$3" + + while :; do + snapshot_log "$source" "$dest" "$limit" + sleep 2 + done +} + +fail() { + + local message="$1" + + echo "[log] ERROR: $message" + if [ -d "$STATE_DIR" ]; then + snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT" + snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT" + rm -f "$STARTED" + fi + exec /usr/libexec/recoveryosd + exit 1 +} + +select_target_disk() { + + local disk info size + local best="" + local best_size=0 + + while IFS= read -r disk; do + + [ -n "$disk" ] || continue + + info=$(/usr/sbin/diskutil info "/dev/$disk" 2>/dev/null || :) + [ -n "$info" ] || continue + + if printf '%s\n' "$info" | + /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then + continue + fi + + size=$(printf '%s\n' "$info" | + /usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | + /usr/bin/head -n 1) + + [[ "$size" =~ ^[0-9]+$ ]] || continue + (( size >= MIN_TARGET_SIZE )) || continue + + echo "[log] candidate target /dev/$disk ($size bytes)" >&2 + + if (( size > best_size )); then + best="$disk" + best_size="$size" + fi + + done < <( + /usr/sbin/diskutil list physical 2>/dev/null | + /usr/bin/sed -nE 's#^/dev/(disk[0-9]+).*#\1#p' + ) + + [ -n "$best" ] || return 1 + printf '/dev/%s\n' "$best" +} + +find_startosinstall() { + + local file + + for file in \ + /Volumes/*/Install\ macOS*.app/Contents/Resources/startosinstall \ + /Volumes/*/Install\ OS\ X*.app/Contents/Resources/startosinstall \ + /Volumes/*/Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall \ + /Install\ macOS*.app/Contents/Resources/startosinstall \ + /Install\ OS\ X*.app/Contents/Resources/startosinstall \ + /Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall \ + /Applications/Install\ macOS*.app/Contents/Resources/startosinstall \ + /Applications/Install\ OS\ X*.app/Contents/Resources/startosinstall \ + /Applications/Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall; do + + if [ -x "$file" ]; then + printf '%s\n' "$file" + return 0 + fi + done + + return 1 +} + +install_bootstrap() { + + local support_dir="$TARGET_VOLUME/Library/Application Support/macos-unattended" + local daemon_dir="$TARGET_VOLUME/Library/LaunchDaemons" + local staged_admin="$support_dir/admin.pkg" + local staged_script="$support_dir/firstboot.sh" + local staged_plist="$daemon_dir/com.dockur.macos.firstboot.plist" + + echo "[log] prepare phase completed; installing unattended bootstrap" + + if ! /usr/sbin/installer \ + -pkg "$SETUP_PACKAGE" \ + -target "$TARGET_VOLUME" \ + -verboseR; then + echo "[log] ERROR: failed to install prebuilt Setup Assistant package" + return 1 + fi + + echo "[log] prebuilt Setup Assistant package installed successfully" + + if ! /bin/mkdir -p "$support_dir" "$daemon_dir"; then + echo "[log] ERROR: failed to create first-boot staging directories" + return 1 + fi + + if ! /bin/cp -f "$ADMIN_PACKAGE" "$staged_admin" || + ! /bin/cp -f "$FIRSTBOOT_SCRIPT" "$staged_script" || + ! /bin/cp -f "$FIRSTBOOT_PLIST" "$staged_plist"; then + echo "[log] ERROR: failed to stage first-boot files" + return 1 + fi + + /usr/sbin/chown 0:0 "$staged_admin" "$staged_script" "$staged_plist" 2>/dev/null || : + + if ! /bin/chmod 0644 "$staged_admin" "$staged_plist" || + ! /bin/chmod 0755 "$staged_script"; then + echo "[log] ERROR: failed to set first-boot file permissions" + return 1 + fi + + if ! /usr/bin/cmp -s "$ADMIN_PACKAGE" "$staged_admin" || + ! /usr/bin/cmp -s "$FIRSTBOOT_SCRIPT" "$staged_script" || + ! /usr/bin/cmp -s "$FIRSTBOOT_PLIST" "$staged_plist"; then + echo "[log] ERROR: staged first-boot files failed byte-for-byte validation" + return 1 + fi + + if ! /usr/bin/plutil -lint "$staged_plist" >/dev/null 2>&1; then + echo "[log] ERROR: staged first-boot LaunchDaemon plist is invalid" + return 1 + fi + + echo "[log] unattended bootstrap installed successfully" + return 0 +} + +run_bootstrapper() { + + trap ' + if install_bootstrap; then + : > "$BOOTSTRAP_DONE" + else + : > "$BOOTSTRAP_FAILED" + fi + exit 0 + ' USR1 + + : > "$BOOTSTRAP_READY" + + while :; do + sleep 60 + done +} + +if ! mount_state_share; then + fail "failed to mount installation state share" +fi + +if ! : > "$STATE_DIR/.write-test" 2>/dev/null; then + fail "installation state share is not writable" +fi +rm -f "$STATE_DIR/.write-test" + +snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT" +mirror_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT" & + +echo "[log] installation state share mounted" + +if [ -e "$STARTED" ]; then + echo "[log] installation was already started; refusing to erase the target disk again" + exec /usr/libexec/recoveryosd + exit 1 +fi + +[ -s "$ADMIN_PACKAGE" ] || fail "account package is missing" +[ -s "$SETUP_PACKAGE" ] || fail "Setup Assistant package is missing" +[ -s "$FIRSTBOOT_SCRIPT" ] || fail "first-boot script is missing" +[ -s "$FIRSTBOOT_PLIST" ] || fail "first-boot LaunchDaemon plist is missing" +[ -x /usr/sbin/installer ] || fail "installer is not available in Recovery" +/usr/bin/plutil -lint "$FIRSTBOOT_PLIST" >/dev/null 2>&1 || + fail "first-boot LaunchDaemon plist is invalid" + +STARTOSINSTALL="" +count=0 +while (( count < 120 )); do + STARTOSINSTALL=$(find_startosinstall || :) + [ -n "$STARTOSINSTALL" ] && break + count=$((count + 1)) + sleep 1 +done + +[ -n "$STARTOSINSTALL" ] || fail "startosinstall was not found in the recovery image" + +echo "[log] using $STARTOSINSTALL" + +USAGE=$("$STARTOSINSTALL" --usage 2>&1 || :) +[ -n "$USAGE" ] || fail "startosinstall did not return usage information" + +printf '%s\n' "$USAGE" | /usr/bin/grep -q -- '--rebootdelay' || + fail "startosinstall does not support --rebootdelay" + +printf '%s\n' "$USAGE" | /usr/bin/grep -q -- '--pidtosignal' || + fail "startosinstall does not support --pidtosignal" + +echo "[log] unattended bootstrap preflight passed" + +TARGET_DISK="" +count=0 +while (( count < 120 )); do + TARGET_DISK=$(select_target_disk || :) + [ -n "$TARGET_DISK" ] && break + count=$((count + 1)) + sleep 1 +done + +[ -n "$TARGET_DISK" ] || fail "no writable installation disk of at least 16 GiB was found" + +echo "[log] selected $TARGET_DISK" + +# Everything needed for the unattended install has passed validation. Only now +# create the guard and perform the destructive target erase. +: > "$STARTED" || fail "failed to create installation guard" + +if ! /usr/sbin/diskutil eraseDisk APFS "Macintosh HD" GPT "$TARGET_DISK"; then + fail "failed to erase $TARGET_DISK as APFS" +fi + +count=0 +while (( count < 60 )); do + [ -d "$TARGET_VOLUME" ] && break + count=$((count + 1)) + sleep 1 +done + +[ -d "$TARGET_VOLUME" ] || fail "target APFS volume did not mount" + +rm -f \ + "$BOOTSTRAP_READY" \ + "$BOOTSTRAP_DONE" \ + "$BOOTSTRAP_FAILED" + +# startosinstall prepares macOS without --installpackage. At the end of the +# prepare phase it signals our helper while the target is still mounted. The +# helper installs the known-good Setup Assistant package directly and stages +# the known-good account package for a LaunchDaemon to run on first real boot. +run_bootstrapper & +BOOTSTRAPPER_PID=$! + +count=0 +while (( count < 10 )); do + [ -e "$BOOTSTRAP_READY" ] && break + count=$((count + 1)) + sleep 1 +done + +[ -e "$BOOTSTRAP_READY" ] || { + /bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || : + fail "bootstrap helper failed to initialize" +} + +ARGS=( + --volume "$TARGET_VOLUME" + --agreetolicense + --nointeraction + --rebootdelay 300 + --pidtosignal "$BOOTSTRAPPER_PID" +) + +echo "[log] starting online macOS installation on $TARGET_VOLUME" +echo "[log] bootstrap will be installed directly after startosinstall prepare phase" + +# Keep Apple's installer diagnostics visible from the host while Recovery is +# occupied by startosinstall. Both host-visible logs are rolling snapshots so +# the 9p-backed shared-memory state can never grow without bound. +if [ -f /var/log/install.log ]; then + snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT" + mirror_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT" & + echo "[log] mirroring Apple install log to $APPLE_INSTALL_LOG" +else + echo "[log] Apple install log is not available" +fi + +"$STARTOSINSTALL" "${ARGS[@]}" & +STARTOSINSTALL_PID=$! + +while /bin/kill -0 "$STARTOSINSTALL_PID" 2>/dev/null; do + + if [ -e "$BOOTSTRAP_DONE" ]; then + echo "[log] bootstrap installation succeeded; releasing startosinstall reboot delay" + /bin/kill -USR1 "$STARTOSINSTALL_PID" 2>/dev/null || : + break + fi + + if [ -e "$BOOTSTRAP_FAILED" ]; then + echo "[log] ERROR: bootstrap installation failed; cancelling prepared install" + /bin/kill "$STARTOSINSTALL_PID" 2>/dev/null || : + wait "$STARTOSINSTALL_PID" 2>/dev/null || : + /bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || : + fail "bootstrap installation failed" + fi + + sleep 1 +done + +wait "$STARTOSINSTALL_PID" +rc=$? + +/bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || : + +if (( rc != 0 )); then + rm -f "$STARTED" + echo "[log] ERROR: startosinstall exited with status $rc" + snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT" + snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT" + printf '%s\n' "$USAGE" + exec /usr/libexec/recoveryosd + exit "$rc" +fi + +echo "[log] startosinstall completed its prepare phase; waiting for reboot" + +while :; do + sleep 60 +done diff --git a/src/install/recovery/patch.py b/src/install/recovery/patch.py new file mode 100644 index 0000000..265772b --- /dev/null +++ b/src/install/recovery/patch.py @@ -0,0 +1,238 @@ +import plistlib +import struct +import sys +import zlib + +UDRW = 0x00000001 +UDZO = 0x80000005 + +SCRIPT_ORIGINAL = b'''# +# launchd passes the "boot mode" to us, if one is set for this boot +# +# in certain boot modes, we tell diskarbitrationd not to automatically +# mount any other volumes. This has to happen here, before launchd +# starts all the daemons, so we can be sure it is set before diskarbitrationd +# starts up. +''' + +SCRIPT_BOOTSTRAP = b'''[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m +''' + +RECOVERY_ORIGINAL = b"/usr/libexec/recoveryosd" +RECOVERY_REPLACEMENT = b"/private/etc/rc.cdrom.sh" + + +def be32(data, offset): + return struct.unpack_from(">I", data, offset)[0] + + +def be64(data, offset): + return struct.unpack_from(">Q", data, offset)[0] + + +def find_all(data, needle): + start = 0 + while True: + offset = data.find(needle, start) + if offset < 0: + return + yield offset + start = offset + 1 + + +def main(): + path = sys.argv[1] + + if len(SCRIPT_BOOTSTRAP) + 2 > len(SCRIPT_ORIGINAL): + raise RuntimeError("Recovery bootstrap is larger than the replaceable rc.cdrom.sh block") + + padding = len(SCRIPT_ORIGINAL) - len(SCRIPT_BOOTSTRAP) + script_replacement = SCRIPT_BOOTSTRAP + b"#" + (b" " * (padding - 2)) + b"\n" + + if len(script_replacement) != len(SCRIPT_ORIGINAL): + raise RuntimeError("Recovery bootstrap replacement length mismatch") + + if len(RECOVERY_REPLACEMENT) != len(RECOVERY_ORIGINAL): + raise RuntimeError("recoveryosd launch-path replacement length mismatch") + + patches = ( + ("rc.cdrom.sh bootstrap", SCRIPT_ORIGINAL, script_replacement), + ("recoveryosd launch path", RECOVERY_ORIGINAL, RECOVERY_REPLACEMENT), + ) + + with open(path, "r+b") as image: + image.seek(0, 2) + size = image.tell() + + if size < 512: + raise RuntimeError("Recovery image is too small to be a DMG") + + image.seek(size - 512) + koly = image.read(512) + + if koly[:4] != b"koly": + raise RuntimeError("Recovery image has no UDIF koly trailer") + + data_fork_offset = be64(koly, 24) + xml_offset = be64(koly, 216) + xml_length = be64(koly, 224) + + image.seek(xml_offset) + plist = plistlib.loads(image.read(xml_length)) + + matches = {name: [] for name, _, _ in patches} + chunks = {} + + # Scan every supported DMG run once. Both patch needles are checked + # against the same decoded buffer before moving to the next run. + for blkx_index, blkx in enumerate(plist["resource-fork"]["blkx"]): + mish = blkx["Data"] + + if mish[:4] != b"mish": + continue + + first_sector = be64(mish, 8) + mish_data_offset = be64(mish, 24) + run_count = (len(mish) - 204) // 40 + + for run_index in range(run_count): + entry = 204 + run_index * 40 + run_type = be32(mish, entry) + + if run_type not in (UDRW, UDZO): + continue + + sector = first_sector + be64(mish, entry + 8) + sectors = be64(mish, entry + 16) + compressed_offset = be64(mish, entry + 24) + compressed_length = be64(mish, entry + 32) + physical_offset = data_fork_offset + mish_data_offset + compressed_offset + + image.seek(physical_offset) + stored = image.read(compressed_length) + + if len(stored) != compressed_length: + raise RuntimeError("Short read while scanning Recovery DMG chunk") + + if run_type == UDRW: + decoded = stored + else: + decoded = zlib.decompress(stored) + + expected = sectors * 512 + if len(decoded) != expected: + raise RuntimeError( + f"DMG chunk {blkx_index}/{run_index} expands to " + f"{len(decoded)} bytes, expected {expected}" + ) + + key = (blkx_index, run_index) + found = False + + for name, original, _ in patches: + for offset in find_all(decoded, original): + matches[name].append((key, offset)) + found = True + + if found: + chunks[key] = { + "run_type": run_type, + "sector": sector, + "sectors": sectors, + "physical_offset": physical_offset, + "compressed_length": compressed_length, + "decoded": decoded, + } + + for name, _, _ in patches: + count = len(matches[name]) + if count != 1: + raise RuntimeError(f"Expected exactly one {name}, found {count}") + + # Apply every patch to its already-decoded chunk. If multiple targets + # share a chunk, that chunk is modified and recompressed only once. + for key, chunk in chunks.items(): + patched = bytearray(chunk["decoded"]) + + for name, original, replacement in patches: + for match_key, offset in matches[name]: + if match_key != key: + continue + + end = offset + len(original) + if bytes(patched[offset:end]) != original: + raise RuntimeError(f"{name} moved before patching") + patched[offset:end] = replacement + + patched = bytes(patched) + + if len(patched) != len(chunk["decoded"]): + raise RuntimeError("Patched DMG chunk changed logical size") + + if chunk["run_type"] == UDRW: + stored = patched + if len(stored) != chunk["compressed_length"]: + raise RuntimeError("Patched raw DMG chunk changed physical size") + else: + compressed = zlib.compress(patched, 9) + if len(compressed) > chunk["compressed_length"]: + raise RuntimeError( + f"Patched DMG chunk {key[0]}/{key[1]} grew from " + f"{chunk['compressed_length']} to {len(compressed)} compressed bytes" + ) + stored = compressed + (b"\0" * (chunk["compressed_length"] - len(compressed))) + + image.seek(chunk["physical_offset"]) + image.write(stored) + + image.flush() + + # Read back only the affected chunk(s), decode them again, and verify + # both replacements at the exact locations found during the single scan. + for key, chunk in chunks.items(): + image.seek(chunk["physical_offset"]) + stored = image.read(chunk["compressed_length"]) + + if len(stored) != chunk["compressed_length"]: + raise RuntimeError("Short read while verifying patched Recovery DMG chunk") + + if chunk["run_type"] == UDRW: + verified = stored + else: + verified = zlib.decompress(stored) + + if len(verified) != chunk["sectors"] * 512: + raise RuntimeError("Patched DMG chunk failed logical-size verification") + + for name, original, replacement in patches: + for match_key, offset in matches[name]: + if match_key != key: + continue + + end = offset + len(replacement) + if verified[offset:end] != replacement: + raise RuntimeError(f"Patched {name} failed byte verification") + if verified.count(original) != 0: + raise RuntimeError(f"Original {name} remains after patching") + + locations = [] + for key in sorted(chunks): + chunk = chunks[key] + kind = "raw" if chunk["run_type"] == UDRW else "zlib" + locations.append( + f"blkx {key[0]}, run {key[1]} ({kind}, sector {chunk['sector']}, " + f"{chunk['compressed_length']} stored bytes)" + ) + + print( + "Patched launch path in " + + "; ".join(locations) + + "." + ) + + +try: + main() +except Exception as exc: + print(f"Recovery bootstrap patch failed: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/install/setup/postinstall b/src/install/setup/postinstall new file mode 100644 index 0000000..8a38903 --- /dev/null +++ b/src/install/setup/postinstall @@ -0,0 +1,114 @@ +#!/bin/bash +set -u + +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH + +TARGET="${3:-/}" +if [ "$TARGET" = "/" ]; then + PREFIX="" +else + PREFIX="${TARGET%/}" +fi + +SYSTEM_PLIST="$PREFIX/System/Library/CoreServices/SystemVersion.plist" +SETUP_PLIST="$PREFIX/Library/User Template/Non_localized/Library/Preferences/com.apple.SetupAssistant.plist" +PLISTBUDDY="/usr/libexec/PlistBuddy" +STATE_DIR="/Volumes/installstate" +LOCAL_LOG="$PREFIX/private/var/log/macos-unattended-packages.log" + +/bin/mkdir -p "${LOCAL_LOG%/*}" 2>/dev/null || : + +mount_state_share() { + + [ -d "$STATE_DIR" ] && return 0 + [ -x /sbin/mount_9p ] || return 1 + + /sbin/mount_9p installstate >/dev/null 2>&1 || : + [ -d "$STATE_DIR" ] +} + +log() { + + local message="[setup] $1" + + printf '%s\n' "$message" >> "$LOCAL_LOG" 2>/dev/null || : + + if mount_state_share; then + printf '%s\n' "$message" >> "$STATE_DIR/packages.log" 2>/dev/null || : + fi +} + +fail() { + log "ERROR: $1" + echo "setup package: $1" >&2 + exit 1 +} + +log "postinstall started (target=$TARGET)" + +# The primary job of this package is to prevent Setup Assistant from launching. +# Do that before optional preference generation so missing version metadata can +# never leave an otherwise completed installation at the country picker. +/bin/mkdir -p "$PREFIX/private/var/db" || + fail "failed to create setup state directory" +/usr/bin/touch "$PREFIX/private/var/db/.AppleSetupDone" || + fail "failed to mark system Setup Assistant complete" +/usr/sbin/chown 0:0 "$PREFIX/private/var/db/.AppleSetupDone" || + fail "failed to set Setup Assistant marker ownership" +/bin/chmod 0644 "$PREFIX/private/var/db/.AppleSetupDone" || + fail "failed to set Setup Assistant marker permissions" + +log "Setup Assistant completion marker created" + +/bin/mkdir -p "$PREFIX/Library/User Template/English.lproj" || + fail "failed to create English user template" +/usr/bin/touch "$PREFIX/Library/User Template/English.lproj/.skipbuddy" || + fail "failed to create first-login skip marker" +/usr/sbin/chown 0:0 "$PREFIX/Library/User Template/English.lproj/.skipbuddy" || + fail "failed to set first-login skip marker ownership" +/bin/chmod 0644 "$PREFIX/Library/User Template/English.lproj/.skipbuddy" || + fail "failed to set first-login skip marker permissions" + +log "first-login skip marker created" + +# The preference template is supplemental. Older/newer installer stages can +# expose the final SystemVersion metadata at different times, but that must not +# undo the core Setup Assistant suppression above. +if [ ! -f "$SYSTEM_PLIST" ]; then + log "SystemVersion.plist unavailable; skipping Setup Assistant preferences" + log "postinstall completed" + exit 0 +fi + +OS_VERSION=$("$PLISTBUDDY" -c 'Print :ProductVersion' "$SYSTEM_PLIST" 2>/dev/null || :) +OS_BUILD=$("$PLISTBUDDY" -c 'Print :ProductBuildVersion' "$SYSTEM_PLIST" 2>/dev/null || :) + +if [ -z "$OS_VERSION" ] || [ -z "$OS_BUILD" ]; then + log "system version metadata incomplete; skipping Setup Assistant preferences" + log "postinstall completed" + exit 0 +fi + +/bin/mkdir -p "${SETUP_PLIST%/*}" || + fail "failed to create Setup Assistant preference directory" + +SETUP_TEMPLATE="${0%/*}/com.apple.SetupAssistant.plist" +[ -f "$SETUP_TEMPLATE" ] || fail "Setup Assistant plist template is missing" + +/usr/bin/sed \ + -e "s|@OS_BUILD@|$OS_BUILD|g" \ + -e "s|@OS_VERSION@|$OS_VERSION|g" \ + "$SETUP_TEMPLATE" > "$SETUP_PLIST" || + fail "failed to generate Setup Assistant plist" + +/usr/bin/plutil -lint "$SETUP_PLIST" >/dev/null || + fail "generated Setup Assistant plist is invalid" +/usr/sbin/chown 0:0 "$SETUP_PLIST" || + fail "failed to set Setup Assistant preference ownership" +/bin/chmod 0644 "$SETUP_PLIST" || + fail "failed to set Setup Assistant preference permissions" + +log "Setup Assistant preferences installed" +log "postinstall completed" +exit 0