Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71c0c3d029 | ||
|
|
30342b4e1b | ||
|
|
592c7294c3 | ||
|
|
5e58722aca | ||
|
|
f5881cd5c8 | ||
|
|
e1148cce7b | ||
|
|
d9707fd57b | ||
|
|
dd29fd92a7 | ||
|
|
2aae652569 | ||
|
|
5468fdc033 |
No files matched your search
@@ -4,10 +4,15 @@
|
|||||||
*.img
|
*.img
|
||||||
*.vhd
|
*.vhd
|
||||||
*.vhdx
|
*.vhdx
|
||||||
|
*.zip
|
||||||
*.part
|
*.part
|
||||||
*.part.url
|
*.part.url
|
||||||
|
*.aria2
|
||||||
|
|
||||||
# Keep the intended directory layout in Git.
|
# Keep the intended directory layout in Git.
|
||||||
!base-qcow2/.gitkeep
|
!base-qcow2/.gitkeep
|
||||||
!install-iso/.gitkeep
|
!install-iso/.gitkeep
|
||||||
!tools-iso/.gitkeep
|
!tools-iso/.gitkeep
|
||||||
|
|
||||||
|
# Node dependencies of nodejs/get-windows-iso-url.js.
|
||||||
|
node_modules/
|
||||||
@@ -30,6 +30,26 @@ arm64 hosts) and `.iso` in that order (`*Win*11*x64*.iso` or
|
|||||||
version-sorted name is used. Check which file was picked with
|
version-sorted name is used. Check which file was picked with
|
||||||
`./download-images.sh --print-path windows11 --iso`.
|
`./download-images.sh --print-path windows11 --iso`.
|
||||||
|
|
||||||
|
To get a fresh temporary URL, `nodejs/get-windows-iso-url.js` drives Microsoft's
|
||||||
|
download page with headless Chromium (needs Node.js and an installed
|
||||||
|
Chromium, for example from Playwright):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nodejs
|
||||||
|
npm ci # once, installs playwright-core
|
||||||
|
node get-windows-iso-url.js # print the URL for the host architecture
|
||||||
|
node get-windows-iso-url.js --arch arm64 # choose the architecture explicitly
|
||||||
|
```
|
||||||
|
|
||||||
|
The script only prints the URL. `./download-images.sh windows11` downloads that
|
||||||
|
URL into `install-iso/` like the other images (aria2c, resumable) for the image
|
||||||
|
architecture (`VM_IMAGE_ARCH=arm64` for arm64), and skips it when a matching ISO
|
||||||
|
is already there. The `all` target does not include it.
|
||||||
|
|
||||||
|
The URL expires after about 24 hours and may be bound to the IP that requested
|
||||||
|
it, so download from the machine that ran the script. Run it with `--help` for
|
||||||
|
the language and path options.
|
||||||
|
|
||||||
## Query image paths
|
## Query image paths
|
||||||
|
|
||||||
The path interface prints one absolute path without downloading anything:
|
The path interface prints one absolute path without downloading anything:
|
||||||
@@ -51,7 +71,8 @@ ISO (for example `Win11_25H2_English_x64_v2.iso` →
|
|||||||
existing `*Win*11*x64*.qcow2` (`*Win*11*arm64*.qcow2` on arm64) in `base-qcow2/` is used instead.
|
existing `*Win*11*x64*.qcow2` (`*Win*11*arm64*.qcow2` on arm64) in `base-qcow2/` is used instead.
|
||||||
|
|
||||||
Download URLs can be overridden with `DEBIAN_IMAGE_URL`, `ALMA_IMAGE_URL`,
|
Download URLs can be overridden with `DEBIAN_IMAGE_URL`, `ALMA_IMAGE_URL`,
|
||||||
`FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, and `VIRTIO_WIN_ISO_URL`. Each may
|
`FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, `VIRTIO_WIN_ISO_URL`, and
|
||||||
|
`OPENSSH_WIN_ZIP_URL`. Each may
|
||||||
list several mirrors of the same file, separated by whitespace; the Fedora
|
list several mirrors of the same file, separated by whitespace; the Fedora
|
||||||
image defaults to the TUNA, BFSU and NJU mirrors, and the Ubuntu image to the
|
image defaults to the TUNA, BFSU and NJU mirrors, and the Ubuntu image to the
|
||||||
TUNA, NJU and official cloud-images.ubuntu.com mirrors.
|
TUNA, NJU and official cloud-images.ubuntu.com mirrors.
|
||||||
@@ -63,6 +84,10 @@ download is only resumed with the same downloader and mirror list; otherwise
|
|||||||
it starts over. The Fedora and Ubuntu checksums can be overridden with
|
it starts over. The Fedora and Ubuntu checksums can be overridden with
|
||||||
`FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image.
|
`FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image.
|
||||||
|
|
||||||
|
The `openssh-win` target downloads the Win32-OpenSSH zip matching the
|
||||||
|
architecture into `tools-iso/`: `OpenSSH-Win64.zip` on amd64 hosts and
|
||||||
|
`OpenSSH-ARM64.zip` on arm64 hosts.
|
||||||
|
|
||||||
## Windows installation disk image
|
## Windows installation disk image
|
||||||
|
|
||||||
QEMU's emulated USB CD-ROM reads the Windows ISO at only a few MB/s on macOS.
|
QEMU's emulated USB CD-ROM reads the Windows ISO at only a few MB/s on macOS.
|
||||||
@@ -79,8 +104,27 @@ sudo apt install wimtools dosfstools gdisk mtools p7zip-full
|
|||||||
./build-windows-install-disk.sh [--force] install-iso/Windows11_..._arm64_....iso
|
./build-windows-install-disk.sh [--force] install-iso/Windows11_..._arm64_....iso
|
||||||
```
|
```
|
||||||
|
|
||||||
The output defaults to the ISO path with `.install.img` in place of `.iso`. The
|
The output defaults to the ISO path with `.img` in place of `.iso`. The
|
||||||
image is built without root. The ISO has to be read as UDF (its ISO 9660 side only
|
image is built without root. The ISO has to be read as UDF (its ISO 9660 side only
|
||||||
holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root,
|
holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root,
|
||||||
otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory
|
otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory
|
||||||
(which needs the free space of the whole ISO). The `*.img` file is ignored by Git.
|
(which needs the free space of the whole ISO). The `*.img` file is ignored by Git.
|
||||||
|
|
||||||
|
## Windows base image
|
||||||
|
|
||||||
|
`run-windows11.py` creates a missing system disk as a QCOW2 overlay on the base
|
||||||
|
image that `./download-images.sh --print-path windows11` prints, so a new VM does
|
||||||
|
not run Windows Setup again. `build-windows-base.sh` makes that base image: it
|
||||||
|
installs Windows unattended from the ISO in `install-iso/`, generalizes it with
|
||||||
|
`sysprep` over SSH, and stores the shut-down disk read-only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-windows-base.sh [--force] [--no-sysprep] [--compress] [OUTPUT.qcow2]
|
||||||
|
```
|
||||||
|
|
||||||
|
It refuses to replace an existing base image without `--force`, because the
|
||||||
|
overlays of existing VMs still point at that file. Build to another `OUTPUT`
|
||||||
|
(for example with a version in its name) and select it with
|
||||||
|
`WIN11_VM_BASE_IMAGE` instead. The installation runs in a scratch directory
|
||||||
|
`/var/tmp/build-windows-base.*` (set `BASE_BUILD_TMPDIR` to use another parent; about 25 GB are needed) and needs a private key that matches
|
||||||
|
the public key put on the answer media.
|
||||||
Executable
+250
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the Windows 11 base QCOW2 image that run-windows11.py clones.
|
||||||
|
#
|
||||||
|
# run-windows11.py creates a missing system disk as a QCOW2 overlay on this
|
||||||
|
# base image, so a new VM starts in seconds instead of running Windows Setup
|
||||||
|
# again. The base image is made once:
|
||||||
|
# 1. run-windows11.py --install-from-iso installs Windows unattended into a
|
||||||
|
# scratch state directory (Autounattend.xml, Configure-Windows.ps1: VirtIO
|
||||||
|
# drivers, OpenSSH, the administrator key from build-seed.sh);
|
||||||
|
# 2. once SSH answers, this script runs sysprep /generalize over SSH, which
|
||||||
|
# removes the machine identity and shuts the guest down;
|
||||||
|
# 3. the shut-down disk is converted into the output file and made read-only.
|
||||||
|
# The first boot of every clone then runs the specialize and OOBE passes of the
|
||||||
|
# answer file below (host name, account, automatic logon) without any prompt.
|
||||||
|
#
|
||||||
|
# Needs the Windows ISO in install-iso/ (see README.md), qemu-img, ssh, scp and
|
||||||
|
# a private key that matches the public key put on the answer media (the
|
||||||
|
# default ~/.ssh keys, or SSH_PUBLIC_KEY_FILE for the public half and
|
||||||
|
# SSH_PRIVATE_KEY_FILE for this script).
|
||||||
|
#
|
||||||
|
# Usage: build-windows-base.sh [--force] [--no-sysprep] [--keep-work] [--compress]
|
||||||
|
# [--ssh-port PORT] [OUTPUT.qcow2]
|
||||||
|
# OUTPUT defaults to the path that "download-images.sh --print-path windows11"
|
||||||
|
# prints, which is where run-windows11.py looks for the base image.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
images_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_dir="$(cd -- "${images_dir}/.." && pwd)"
|
||||||
|
launcher="${repo_dir}/run-windows11.py"
|
||||||
|
downloader="${images_dir}/download-images.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [--force] [--no-sysprep] [--keep-work] [--compress]
|
||||||
|
[--ssh-port PORT] [OUTPUT.qcow2]
|
||||||
|
|
||||||
|
Install Windows 11 from the ISO, generalize it with sysprep and store the disk
|
||||||
|
as the read-only base image that run-windows11.py clones.
|
||||||
|
|
||||||
|
--force Replace OUTPUT if it exists
|
||||||
|
--no-sysprep Skip sysprep and only shut the guest down; clones then share
|
||||||
|
the machine SID and host name (fine for stand-alone VMs)
|
||||||
|
--keep-work Keep the scratch state directory (also kept on failure)
|
||||||
|
--compress Compress the output (smaller, but clones read it slower)
|
||||||
|
--ssh-port PORT Host port forwarded to the guest's SSH during the build
|
||||||
|
(default: 2299; overrides BASE_BUILD_SSH_PORT)
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
SSH_PRIVATE_KEY_FILE Private key to log in with (default: ssh's own choice)
|
||||||
|
SSH_PUBLIC_KEY_FILE Public key put on the answer media (see build-seed.sh)
|
||||||
|
VM_IMAGE_ARCH amd64 or arm64 (default: the host's)
|
||||||
|
BASE_BUILD_TMPDIR Parent of the scratch state directory, which needs about
|
||||||
|
25 GB (default: /var/tmp)
|
||||||
|
BASE_BUILD_TIMEOUT Seconds to wait for the installation (default: 5400)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'Error: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
force=0
|
||||||
|
sysprep=1
|
||||||
|
keep_work=0
|
||||||
|
compress=0
|
||||||
|
ssh_port="${BASE_BUILD_SSH_PORT:-2299}"
|
||||||
|
output=""
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--force) force=1 ;;
|
||||||
|
--no-sysprep) sysprep=0 ;;
|
||||||
|
--keep-work) keep_work=1 ;;
|
||||||
|
--compress) compress=1 ;;
|
||||||
|
--ssh-port) shift; ssh_port="${1:?--ssh-port needs a value}" ;;
|
||||||
|
--ssh-port=*) ssh_port="${1#*=}" ;;
|
||||||
|
-*) usage >&2; exit 2 ;;
|
||||||
|
*) [[ -z "$output" ]] || { usage >&2; exit 2; }; output="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[[ "$ssh_port" =~ ^[0-9]+$ ]] || die "--ssh-port must be a number: ${ssh_port}"
|
||||||
|
|
||||||
|
for tool in qemu-img ssh scp python3; do
|
||||||
|
command -v "$tool" >/dev/null || die "${tool} is not installed."
|
||||||
|
done
|
||||||
|
[[ -x "$launcher" ]] || die "run-windows11.py was not found: ${launcher}"
|
||||||
|
[[ -x "$downloader" ]] || die "download-images.sh was not found: ${downloader}"
|
||||||
|
|
||||||
|
output="${output:-$("$downloader" --print-path windows11)}"
|
||||||
|
if [[ -e "$output" && $force -eq 0 ]]; then
|
||||||
|
die "${output} exists (it may be the backing file of existing VM disks); use --force or another OUTPUT."
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${VM_IMAGE_ARCH:-$(uname -m)}" in
|
||||||
|
arm64|aarch64) arch=arm64 ;;
|
||||||
|
x86_64|amd64) arch=amd64 ;;
|
||||||
|
*) die "unsupported architecture: ${VM_IMAGE_ARCH:-$(uname -m)}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
total_steps=5
|
||||||
|
step_number=0
|
||||||
|
step() {
|
||||||
|
step_number=$((step_number + 1))
|
||||||
|
printf '[%d/%d] %s\n' "$step_number" "$total_steps" "$*"
|
||||||
|
}
|
||||||
|
note() {
|
||||||
|
printf ' %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "${BASE_BUILD_TMPDIR:-/var/tmp}"
|
||||||
|
work="$(mktemp -d "${BASE_BUILD_TMPDIR:-/var/tmp}/build-windows-base.XXXXXX")"
|
||||||
|
pid_file="${work}/qemu.pid"
|
||||||
|
succeeded=0
|
||||||
|
|
||||||
|
ssh_options=(-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
|
||||||
|
-o LogLevel=ERROR -o ConnectTimeout=5)
|
||||||
|
[[ -z "${SSH_PRIVATE_KEY_FILE:-}" ]] || ssh_options+=(-i "$SSH_PRIVATE_KEY_FILE")
|
||||||
|
guest_ssh() { ssh -n "${ssh_options[@]}" -p "$ssh_port" guofang@127.0.0.1 "$@"; }
|
||||||
|
|
||||||
|
vm_running() {
|
||||||
|
[[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if vm_running; then
|
||||||
|
kill "$(cat "$pid_file")" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if ((succeeded && !keep_work)); then
|
||||||
|
rm -rf "$work"
|
||||||
|
else
|
||||||
|
note "Scratch directory kept: ${work}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Poll "$@" every 10 s until it succeeds; give up after $1 seconds.
|
||||||
|
wait_for() {
|
||||||
|
local limit="$1"
|
||||||
|
shift
|
||||||
|
local deadline=$((SECONDS + limit))
|
||||||
|
until "$@"; do
|
||||||
|
((SECONDS < deadline)) || return 1
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only the SSH rule is forwarded so that a running VM's RDP/DSH ports do not clash.
|
||||||
|
printf 'SSH %s 22\n' "$ssh_port" >"${work}/forwards.conf"
|
||||||
|
|
||||||
|
step "Installing Windows 11 from the ISO (unattended; takes a while)"
|
||||||
|
WIN11_VM_FORWARDS="${work}/forwards.conf" "$launcher" \
|
||||||
|
--daemon --vnc=127.0.0.1:90 --install-from-iso --arch "$arch" --state-dir "$work"
|
||||||
|
|
||||||
|
step "Waiting for the guest to finish its first-logon setup (SSH answers)"
|
||||||
|
note "Screen: VNC 127.0.0.1:5990; console log: ${work}/console.log"
|
||||||
|
if ! wait_for "${BASE_BUILD_TIMEOUT:-5400}" guest_ssh 'echo ready' >/dev/null 2>&1; then
|
||||||
|
die "SSH did not answer in time; inspect the guest over VNC."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The answer file the clones use on their first boot. generalize removes the
|
||||||
|
# machine identity but keeps the guofang account, so OOBE only needs the
|
||||||
|
# language, the EULA and the automatic logon; the specialize pass restores the
|
||||||
|
# settings Autounattend.xml applies (host name, time zone, Remote Desktop).
|
||||||
|
write_sysprep_answer() {
|
||||||
|
cat >"$1" <<EOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<ComputerName>WIN11</ComputerName>
|
||||||
|
<TimeZone>China Standard Time</TimeZone>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
|
||||||
|
processorArchitecture="${arch}" publicKeyToken="31bf3856ad364e35" language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<fDenyTSConnections>false</fDenyTSConnections>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-International-Core" processorArchitecture="${arch}"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<InputLocale>en-US</InputLocale>
|
||||||
|
<SystemLocale>en-US</SystemLocale>
|
||||||
|
<UILanguage>en-US</UILanguage>
|
||||||
|
<UserLocale>en-US</UserLocale>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<OOBE>
|
||||||
|
<HideEULAPage>true</HideEULAPage>
|
||||||
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||||
|
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||||
|
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||||
|
<SkipUserOOBE>true</SkipUserOOBE>
|
||||||
|
<ProtectYourPC>3</ProtectYourPC>
|
||||||
|
</OOBE>
|
||||||
|
<AutoLogon>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<Username>guofang</Username>
|
||||||
|
<Password>
|
||||||
|
<Value>wang</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
</AutoLogon>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((sysprep)); then
|
||||||
|
step "Generalizing the guest with sysprep"
|
||||||
|
write_sysprep_answer "${work}/sysprep-unattend.xml"
|
||||||
|
scp "${ssh_options[@]}" -P "$ssh_port" "${work}/sysprep-unattend.xml" \
|
||||||
|
guofang@127.0.0.1:C:/Windows/Temp/sysprep-unattend.xml
|
||||||
|
# The connection drops when the guest shuts down, which ssh reports as 255.
|
||||||
|
status=0
|
||||||
|
guest_ssh 'C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown /quiet /unattend:C:\Windows\Temp\sysprep-unattend.xml' || status=$?
|
||||||
|
if ((status != 0 && status != 255)); then
|
||||||
|
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
|
||||||
|
die "sysprep failed (exit code ${status}); the log is above."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
step "Shutting the guest down (sysprep skipped)"
|
||||||
|
guest_ssh 'shutdown /s /t 5' || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Waiting for the guest to power off"
|
||||||
|
if ! wait_for 900 bash -c '! kill -0 "$(cat "$1")" 2>/dev/null' _ "$pid_file"; then
|
||||||
|
if ((sysprep)); then
|
||||||
|
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
|
||||||
|
fi
|
||||||
|
die "the guest did not power off; sysprep probably failed (log above, if SSH still answers)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Converting the disk to ${output}"
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
convert_options=(-f qcow2 -O qcow2)
|
||||||
|
((compress)) && convert_options+=(-c)
|
||||||
|
rm -f "${output}.part"
|
||||||
|
qemu-img convert -p "${convert_options[@]}" "${work}/disk.qcow2" "${output}.part"
|
||||||
|
[[ ! -e "$output" ]] || chmod u+w "$output"
|
||||||
|
mv -f "${output}.part" "$output"
|
||||||
|
chmod a-w "$output"
|
||||||
|
succeeded=1
|
||||||
|
printf 'Windows 11 base image is ready: %s\n' "$output"
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
#
|
#
|
||||||
# Dependencies
|
# Dependencies
|
||||||
# macOS (Homebrew) wimlib dosfstools gptfdisk mtools
|
# macOS (Homebrew) wimlib dosfstools gptfdisk mtools
|
||||||
# Debian wimtools dosfstools gdisk mtools udisks2
|
# Debian wimtools dosfstools gdisk mtools rsync udisks2
|
||||||
#
|
#
|
||||||
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
|
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
|
||||||
# OUTPUT defaults to the ISO path with ".install.img" in place of ".iso".
|
# OUTPUT defaults to the ISO path with ".img" in place of ".iso".
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -39,7 +39,7 @@ if ((${#args[@]} < 1 || ${#args[@]} > 2)); then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
iso="${args[0]}"
|
iso="${args[0]}"
|
||||||
output="${args[1]:-${iso%.*}.install.img}"
|
output="${args[1]:-${iso%.*}.img}"
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf 'Error: %s\n' "$1" >&2
|
printf 'Error: %s\n' "$1" >&2
|
||||||
@@ -60,6 +60,18 @@ note() {
|
|||||||
printf ' %s\n' "$*" >&2
|
printf ' %s\n' "$*" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# show logs a command line before it runs; run logs it and then runs it. Use show
|
||||||
|
# alone where the output of the command is captured.
|
||||||
|
show() {
|
||||||
|
local line
|
||||||
|
printf -v line '%q ' "$@"
|
||||||
|
note "\$ ${line% }"
|
||||||
|
}
|
||||||
|
run() {
|
||||||
|
show "$@"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
os="$(uname -s)"
|
os="$(uname -s)"
|
||||||
case "$os" in
|
case "$os" in
|
||||||
Darwin|Linux) ;;
|
Darwin|Linux) ;;
|
||||||
@@ -76,7 +88,7 @@ for dir in ${brew_prefix:+"$brew_prefix/sbin"} /opt/homebrew/sbin /usr/local/sbi
|
|||||||
[[ -d "$dir" ]] && PATH="$PATH:$dir"
|
[[ -d "$dir" ]] && PATH="$PATH:$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
tools=(wimlib-imagex mkfs.fat sgdisk mcopy mmd)
|
tools=(wimlib-imagex mkfs.fat sgdisk mcopy rsync)
|
||||||
[[ "$os" == Darwin ]] && tools+=(hdiutil)
|
[[ "$os" == Darwin ]] && tools+=(hdiutil)
|
||||||
missing=()
|
missing=()
|
||||||
for tool in "${tools[@]}"; do
|
for tool in "${tools[@]}"; do
|
||||||
@@ -86,7 +98,7 @@ if ((${#missing[@]} > 0)); then
|
|||||||
if [[ "$os" == Darwin ]]; then
|
if [[ "$os" == Darwin ]]; then
|
||||||
hint="brew install wimlib dosfstools gptfdisk mtools"
|
hint="brew install wimlib dosfstools gptfdisk mtools"
|
||||||
else
|
else
|
||||||
hint="apt install wimtools dosfstools gdisk mtools"
|
hint="apt install wimtools dosfstools gdisk mtools rsync"
|
||||||
fi
|
fi
|
||||||
die "missing tools: ${missing[*]} ($hint)"
|
die "missing tools: ${missing[*]} ($hint)"
|
||||||
fi
|
fi
|
||||||
@@ -99,19 +111,27 @@ fi
|
|||||||
# stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU).
|
# stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU).
|
||||||
if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi
|
if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi
|
||||||
|
|
||||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/win11-install-disk.XXXXXX")"
|
# The work directory holds about 10 GiB, so it defaults to /var/tmp: /tmp is
|
||||||
|
# memory-backed on some systems (Debian 13, Fedora, Arch).
|
||||||
|
work_dir="$(mktemp -d "${TMPDIR:-/var/tmp}/win11-install-disk.XXXXXX")"
|
||||||
mount_dir="$work_dir/iso"
|
mount_dir="$work_dir/iso"
|
||||||
mkdir "$mount_dir"
|
mkdir "$mount_dir"
|
||||||
note "work directory: $work_dir"
|
note "work directory: $work_dir"
|
||||||
mount_method=""
|
mount_method=""
|
||||||
loop_device=""
|
loop_device=""
|
||||||
|
# Set while the output image is being written, so that a failed or cancelled
|
||||||
|
# run does not leave a half-built image behind.
|
||||||
|
partial_output=""
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
[[ -z "$partial_output" ]] || rm -f "$partial_output"
|
||||||
case "$mount_method" in
|
case "$mount_method" in
|
||||||
hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;;
|
hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;;
|
||||||
udisks)
|
udisks)
|
||||||
udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true
|
udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true
|
||||||
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;;
|
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;;
|
||||||
esac
|
esac
|
||||||
|
# Staged directories may be read-only when the run was cancelled early.
|
||||||
|
chmod -R u+rwX "$work_dir" >/dev/null 2>&1 || true
|
||||||
rm -rf "$work_dir"
|
rm -rf "$work_dir"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -127,6 +147,7 @@ mount_with_udisks() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
local setup
|
local setup
|
||||||
|
show udisksctl loop-setup -r -f "$iso"
|
||||||
if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then
|
if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then
|
||||||
note "udisksctl loop-setup failed: $setup"
|
note "udisksctl loop-setup failed: $setup"
|
||||||
return 1
|
return 1
|
||||||
@@ -142,8 +163,10 @@ mount_with_udisks() {
|
|||||||
# A desktop session may have mounted the loop device already, which makes
|
# A desktop session may have mounted the loop device already, which makes
|
||||||
# udisksctl mount fail with AlreadyMounted; use that mount point then.
|
# udisksctl mount fail with AlreadyMounted; use that mount point then.
|
||||||
local mounted point
|
local mounted point
|
||||||
|
show lsblk -no MOUNTPOINT "$loop_device"
|
||||||
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
|
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
|
||||||
if [[ -z "$point" ]]; then
|
if [[ -z "$point" ]]; then
|
||||||
|
show udisksctl mount -b "$loop_device" -t udf -o ro
|
||||||
if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then
|
if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then
|
||||||
# Some udisks versions end the message with a period, others do not.
|
# Some udisks versions end the message with a period, others do not.
|
||||||
point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")"
|
point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")"
|
||||||
@@ -157,7 +180,7 @@ mount_with_udisks() {
|
|||||||
fi
|
fi
|
||||||
if [[ ! -d "$point" ]]; then
|
if [[ ! -d "$point" ]]; then
|
||||||
note "no usable mount point for $loop_device (parsed mount point: '${point}')"
|
note "no usable mount point for $loop_device (parsed mount point: '${point}')"
|
||||||
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
|
run udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
|
||||||
mount_method=""
|
mount_method=""
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -169,7 +192,7 @@ step "Reading the ISO $iso (it is UDF, so it has to be mounted)"
|
|||||||
note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes"
|
note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes"
|
||||||
if [[ "$os" == Darwin ]]; then
|
if [[ "$os" == Darwin ]]; then
|
||||||
note "mounting with hdiutil at $mount_dir"
|
note "mounting with hdiutil at $mount_dir"
|
||||||
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
|
run hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
|
||||||
mount_method=hdiutil
|
mount_method=hdiutil
|
||||||
else
|
else
|
||||||
note "mounting with udisksctl"
|
note "mounting with udisksctl"
|
||||||
@@ -183,20 +206,31 @@ install_wim="$mount_dir/sources/install.wim"
|
|||||||
[[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?"
|
[[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?"
|
||||||
note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes"
|
note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes"
|
||||||
|
|
||||||
|
# The staging directory holds everything that goes into the image: the ISO files
|
||||||
|
# without install.wim, and the install.swm parts in sources/.
|
||||||
|
stage_dir="$work_dir/stage"
|
||||||
|
mkdir "$stage_dir"
|
||||||
|
step "Staging the ISO files without install.wim"
|
||||||
|
# The UDF mount is read-only, so its directories are too; --chmod keeps the copies
|
||||||
|
# writable, otherwise cleanup cannot remove them.
|
||||||
|
run rsync -rt --chmod=u+rwX --exclude=/sources/install.wim "$mount_dir/" "$stage_dir/"
|
||||||
|
if [[ "$os" == Darwin ]]; then
|
||||||
|
# macOS's openrsync ignores --chmod, so make the copies writable here (the split
|
||||||
|
# below writes into sources/).
|
||||||
|
run chmod -R u+rwX "$stage_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
# Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB.
|
# Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB.
|
||||||
step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)"
|
step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)"
|
||||||
split_dir="$work_dir/split"
|
run wimlib-imagex split "$install_wim" "$stage_dir/sources/install.swm" 3800 >/dev/null
|
||||||
mkdir "$split_dir"
|
for part in "$stage_dir"/sources/install*.swm; do
|
||||||
wimlib-imagex split "$install_wim" "$split_dir/install.swm" 3800 >/dev/null
|
|
||||||
for part in "$split_dir"/install*.swm; do
|
|
||||||
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
|
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Size the image: everything but install.wim, the split parts, and 5% plus 64 MiB
|
# Size the image: the staged files, and 5% plus 64 MiB of room for FAT32
|
||||||
# of room for FAT32 metadata and partition alignment.
|
# metadata and partition alignment.
|
||||||
content_bytes=$(
|
content_bytes=$(
|
||||||
{ find "$mount_dir" -type f ! -path "$install_wim" -exec stat "${size_args[@]}" {} +
|
find "$stage_dir" -type f -exec stat "${size_args[@]}" {} + |
|
||||||
find "$split_dir" -type f -exec stat "${size_args[@]}" {} +; } |
|
|
||||||
awk '{ total += $1 } END { printf "%d", total }'
|
awk '{ total += $1 } END { printf "%d", total }'
|
||||||
)
|
)
|
||||||
mib=$((1024 * 1024))
|
mib=$((1024 * 1024))
|
||||||
@@ -204,42 +238,30 @@ image_mib=$(( (content_bytes + content_bytes / 20) / mib + 64 ))
|
|||||||
step "Creating a $image_mib MiB image with a GPT and one FAT32 partition"
|
step "Creating a $image_mib MiB image with a GPT and one FAT32 partition"
|
||||||
note "content to copy: $content_bytes bytes (without install.wim)"
|
note "content to copy: $content_bytes bytes (without install.wim)"
|
||||||
|
|
||||||
image="$work_dir/disk.img"
|
image="$output"
|
||||||
truncate -s "${image_mib}m" "$image"
|
run mkdir -p "$(dirname -- "$output")"
|
||||||
sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
|
# --force replaces an existing image; truncate alone would keep its old content.
|
||||||
|
run rm -f "$image"
|
||||||
|
partial_output="$image"
|
||||||
|
run truncate -s "${image_mib}m" "$image"
|
||||||
|
run sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
|
||||||
|
show sgdisk -i 1 "$image"
|
||||||
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
|
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
|
||||||
[[ -n "$sectors" ]] || die "could not read the partition size"
|
[[ -n "$sectors" ]] || die "could not read the partition size"
|
||||||
mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
|
run mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
|
||||||
|
|
||||||
# mtools addresses the partition by its byte offset inside the image.
|
# mtools addresses the partition by its byte offset inside the image.
|
||||||
mtools_image="$image@@$((2048 * 512))"
|
mtools_image="$image@@$((2048 * 512))"
|
||||||
note "partition: $sectors sectors, formatted as FAT32 (label WINPE)"
|
note "partition: $sectors sectors, formatted as FAT32 (label WINPE)"
|
||||||
export MTOOLS_SKIP_CHECK=1
|
export MTOOLS_SKIP_CHECK=1
|
||||||
|
|
||||||
copy_dir() { # copy_dir SOURCE_DIR TARGET_DIR_IN_IMAGE
|
|
||||||
local source="$1" target="$2" files=() entry
|
|
||||||
while IFS= read -r -d '' entry; do
|
|
||||||
files+=("$entry")
|
|
||||||
done < <(find "$source" -maxdepth 1 -type f ! -path "$install_wim" -print0)
|
|
||||||
if ((${#files[@]} > 0)); then
|
|
||||||
mcopy -Q -m -i "$mtools_image" "${files[@]}" "::$target/"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
step "Copying the installation files into the image"
|
step "Copying the installation files into the image"
|
||||||
copy_dir "$mount_dir" ""
|
entries=()
|
||||||
dir_count=0
|
while IFS= read -r -d '' entry; do
|
||||||
while IFS= read -r -d '' dir; do
|
entries+=("$entry")
|
||||||
relative="${dir#"$mount_dir"/}"
|
done < <(find "$stage_dir" -mindepth 1 -maxdepth 1 -print0)
|
||||||
dir_count=$((dir_count + 1))
|
note "\$ mcopy -s -Q -m -i $mtools_image <${#entries[@]} entries from the staging directory> ::/"
|
||||||
mmd -i "$mtools_image" "::$relative"
|
mcopy -s -Q -m -i "$mtools_image" "${entries[@]}" "::/"
|
||||||
copy_dir "$dir" "$relative"
|
|
||||||
done < <(find "$mount_dir" -mindepth 1 -type d -print0 | sort -z)
|
|
||||||
note "copied $dir_count directories"
|
|
||||||
note "copying the install*.swm parts to sources/"
|
|
||||||
mcopy -Q -m -i "$mtools_image" "$split_dir"/install*.swm "::sources/"
|
|
||||||
|
|
||||||
step "Moving the image to $output"
|
partial_output=""
|
||||||
mkdir -p "$(dirname -- "$output")"
|
|
||||||
mv -f "$image" "$output"
|
|
||||||
printf 'Built Windows 11 installation disk: %s\n' "$output"
|
printf 'Built Windows 11 installation disk: %s\n' "$output"
|
||||||
+67
-3
@@ -57,6 +57,15 @@ fi
|
|||||||
|
|
||||||
virtio_image="${tools_iso_dir}/virtio-win.iso"
|
virtio_image="${tools_iso_dir}/virtio-win.iso"
|
||||||
|
|
||||||
|
# Win32-OpenSSH names its zip after the architecture: Win64 for x64 and ARM64
|
||||||
|
# for arm64.
|
||||||
|
if [[ "$image_arch" == arm64 ]]; then
|
||||||
|
openssh_zip=OpenSSH-ARM64.zip
|
||||||
|
else
|
||||||
|
openssh_zip=OpenSSH-Win64.zip
|
||||||
|
fi
|
||||||
|
openssh_image="${tools_iso_dir}/${openssh_zip}"
|
||||||
|
|
||||||
# Windows 11 media is named after the architecture: x64 on amd64 hosts and
|
# Windows 11 media is named after the architecture: x64 on amd64 hosts and
|
||||||
# Arm64 on arm64 hosts.
|
# Arm64 on arm64 hosts.
|
||||||
if [[ "$image_arch" == arm64 ]]; then
|
if [[ "$image_arch" == arm64 ]]; then
|
||||||
@@ -111,6 +120,7 @@ debian_url="${DEBIAN_IMAGE_URL:-$debian_url_default}"
|
|||||||
alma_url="${ALMA_IMAGE_URL:-$alma_url_default}"
|
alma_url="${ALMA_IMAGE_URL:-$alma_url_default}"
|
||||||
fedora_url="${FEDORA_IMAGE_URL:-$fedora_url_default}"
|
fedora_url="${FEDORA_IMAGE_URL:-$fedora_url_default}"
|
||||||
ubuntu_url="${UBUNTU_IMAGE_URL:-$ubuntu_url_default}"
|
ubuntu_url="${UBUNTU_IMAGE_URL:-$ubuntu_url_default}"
|
||||||
|
openssh_url="${OPENSSH_WIN_ZIP_URL:-https://github.com/PowerShell/Win32-OpenSSH/releases/download/10.0.0.0p2-Preview/${openssh_zip}}"
|
||||||
virtio_url="${VIRTIO_WIN_ISO_URL:-https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso}"
|
virtio_url="${VIRTIO_WIN_ISO_URL:-https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso}"
|
||||||
debian_sha512="${DEBIAN_IMAGE_SHA512:-$debian_sha512_default}"
|
debian_sha512="${DEBIAN_IMAGE_SHA512:-$debian_sha512_default}"
|
||||||
alma_sha256="${ALMA_IMAGE_SHA256:-$alma_sha256_default}"
|
alma_sha256="${ALMA_IMAGE_SHA256:-$alma_sha256_default}"
|
||||||
@@ -120,7 +130,7 @@ force_download=false
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [--force] [all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win] ...
|
Usage: $(basename "$0") [--force] [all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win|openssh-win|windows11] ...
|
||||||
$(basename "$0") --print-path TARGET [--iso]
|
$(basename "$0") --print-path TARGET [--iso]
|
||||||
|
|
||||||
Download one or more shared VM images. With no arguments, all targets are
|
Download one or more shared VM images. With no arguments, all targets are
|
||||||
@@ -131,6 +141,10 @@ When aria2c is installed and an image has several mirrors, the image is
|
|||||||
downloaded from all of them at once, with one connection per mirror.
|
downloaded from all of them at once, with one connection per mirror.
|
||||||
Otherwise curl or wget downloads from the mirrors in order.
|
Otherwise curl or wget downloads from the mirrors in order.
|
||||||
|
|
||||||
|
The windows11 target is not part of "all". It downloads the Windows 11 ISO for
|
||||||
|
the image architecture with nodejs/get-windows-iso-url.js, which needs Node.js,
|
||||||
|
\`npm ci\` in nodejs/ and an installed Chromium (see README.md).
|
||||||
|
|
||||||
--force removes existing files and partial downloads for the selected targets,
|
--force removes existing files and partial downloads for the selected targets,
|
||||||
then downloads them again from the beginning.
|
then downloads them again from the beginning.
|
||||||
--print-path prints only the absolute image path without downloading it.
|
--print-path prints only the absolute image path without downloading it.
|
||||||
@@ -150,6 +164,7 @@ print_image_path() {
|
|||||||
fedora44) printf '%s\n' "$fedora_image" ;;
|
fedora44) printf '%s\n' "$fedora_image" ;;
|
||||||
ubuntu2604) printf '%s\n' "$ubuntu_image" ;;
|
ubuntu2604) printf '%s\n' "$ubuntu_image" ;;
|
||||||
virtio-win) printf '%s\n' "$virtio_image" ;;
|
virtio-win) printf '%s\n' "$virtio_image" ;;
|
||||||
|
openssh-win) printf '%s\n' "$openssh_image" ;;
|
||||||
windows11)
|
windows11)
|
||||||
if [[ "$media" == iso ]]; then
|
if [[ "$media" == iso ]]; then
|
||||||
windows_iso_path
|
windows_iso_path
|
||||||
@@ -195,7 +210,7 @@ fi
|
|||||||
|
|
||||||
for target in "${targets[@]}"; do
|
for target in "${targets[@]}"; do
|
||||||
case "$target" in
|
case "$target" in
|
||||||
all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win) ;;
|
all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win|openssh-win|windows11) ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) printf 'Error: unknown download target: %s\n' "$target" >&2; usage >&2; exit 2 ;;
|
*) printf 'Error: unknown download target: %s\n' "$target" >&2; usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -286,6 +301,9 @@ download_file() {
|
|||||||
local destination="$3"
|
local destination="$3"
|
||||||
local checksum_algorithm="${4:-}"
|
local checksum_algorithm="${4:-}"
|
||||||
local expected_checksum="${5:-}"
|
local expected_checksum="${5:-}"
|
||||||
|
# Signed URLs change on every request; this key identifies the source for
|
||||||
|
# resuming a partial download instead of the URLs when it is given.
|
||||||
|
local source_key="${6:-}"
|
||||||
local partial="${destination}.part"
|
local partial="${destination}.part"
|
||||||
# Records how the partial file was started, so that a partial download is
|
# Records how the partial file was started, so that a partial download is
|
||||||
# only resumed with the same downloader and URLs.
|
# only resumed with the same downloader and URLs.
|
||||||
@@ -329,7 +347,7 @@ download_file() {
|
|||||||
|
|
||||||
# aria2c fills a partial file out of order, so it must not be resumed by
|
# aria2c fills a partial file out of order, so it must not be resumed by
|
||||||
# curl or wget, and vice versa.
|
# curl or wget, and vice versa.
|
||||||
download_state="$(printf '%s\n' "$downloader" "${urls[@]}")"
|
download_state="$(printf '%s\n' "$downloader" "${source_key:-${urls[@]}}")"
|
||||||
if [[ -e "$partial" ]]; then
|
if [[ -e "$partial" ]]; then
|
||||||
if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then
|
if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then
|
||||||
printf 'Download source changed; restarting partial download.\n'
|
printf 'Download source changed; restarting partial download.\n'
|
||||||
@@ -375,6 +393,41 @@ download_file() {
|
|||||||
printf 'Downloaded %s:\n%s\n' "$label" "$destination"
|
printf 'Downloaded %s:\n%s\n' "$label" "$destination"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The Microsoft download link is temporary, so it is fetched with headless
|
||||||
|
# Chromium only when no matching ISO exists yet. "all" does not include it.
|
||||||
|
download_windows_iso() {
|
||||||
|
local iso
|
||||||
|
local url
|
||||||
|
local name
|
||||||
|
|
||||||
|
if [[ "$force_download" == true ]]; then
|
||||||
|
printf 'Forcing a fresh download of the Windows 11 %s ISO.\n' "$windows_arch"
|
||||||
|
while iso="$(find_windows_image "$install_iso_dir" iso)"; do
|
||||||
|
rm -f -- "$iso"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if iso="$(find_windows_image "$install_iso_dir" iso)"; then
|
||||||
|
printf 'Windows 11 %s ISO already exists; skipping:\n%s\n' "$windows_arch" "$iso"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
printf 'Error: node is required to get the Windows 11 ISO URL.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
url="$(node "${images_dir}/nodejs/get-windows-iso-url.js" --arch "$windows_arch")"
|
||||||
|
[[ -n "$url" ]] || { printf 'Error: no Windows 11 ISO URL.\n' >&2; exit 1; }
|
||||||
|
name="${url%%\?*}"
|
||||||
|
name="${name##*/}"
|
||||||
|
name="$(printf '%b' "${name//%/\\x}")"
|
||||||
|
download_file \
|
||||||
|
"Windows 11 ${windows_arch} ISO" \
|
||||||
|
"$url" \
|
||||||
|
"${install_iso_dir}/${name}" \
|
||||||
|
'' \
|
||||||
|
'' \
|
||||||
|
"$name"
|
||||||
|
}
|
||||||
|
|
||||||
if wants_target debian13; then
|
if wants_target debian13; then
|
||||||
download_file \
|
download_file \
|
||||||
'Debian 13 cloud image' \
|
'Debian 13 cloud image' \
|
||||||
@@ -417,3 +470,14 @@ if wants_target virtio-win; then
|
|||||||
"$virtio_url" \
|
"$virtio_url" \
|
||||||
"$virtio_image"
|
"$virtio_image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if wants_target openssh-win; then
|
||||||
|
download_file \
|
||||||
|
'Win32-OpenSSH zip' \
|
||||||
|
"$openssh_url" \
|
||||||
|
"$openssh_image"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ " ${targets[*]} " == *" windows11 "* ]]; then
|
||||||
|
download_windows_iso
|
||||||
|
fi
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Print a signed, temporary download URL for the Windows 11 ISO by driving
|
||||||
|
// Microsoft's download page with headless Chromium.
|
||||||
|
//
|
||||||
|
// Usage: node get-windows-iso-url.js [--arch x64|arm64] [--lang NAME]
|
||||||
|
// Environment:
|
||||||
|
// PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
|
||||||
|
// CHROME_PATH Chromium executable (default: newest one in ~/.cache/ms-playwright)
|
||||||
|
// The URL expires after about 24 hours and may be bound to the IP that
|
||||||
|
// requested it. Only the URL goes to stdout; logs go to stderr.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
console.log(`Usage: node get-windows-iso-url.js [options]
|
||||||
|
|
||||||
|
Print a signed, temporary download URL for the Windows 11 ISO (only the URL
|
||||||
|
goes to stdout; logs go to stderr).
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--arch <x64|arm64> Architecture, default: the host's (x64 on amd64 hosts)
|
||||||
|
--lang <name> Language, default "English (United States)",
|
||||||
|
for example "Chinese Simplified"
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
|
||||||
|
CHROME_PATH Chromium executable (default: newest one in
|
||||||
|
~/.cache/ms-playwright)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
node get-windows-iso-url.js --arch x64 --lang "Chinese Simplified"
|
||||||
|
wget -c "$(node get-windows-iso-url.js 2>/dev/null)"
|
||||||
|
|
||||||
|
The URL expires after about 24 hours and may be bound to the IP that
|
||||||
|
requested it.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const opt = (name, def) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i < 0 ? def : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true);
|
||||||
|
};
|
||||||
|
const hostArch = { x64: 'x64', arm64: 'arm64' }[process.arch];
|
||||||
|
const arch = opt('--arch', hostArch);
|
||||||
|
const lang = opt('--lang', 'English (United States)');
|
||||||
|
const pages = { arm64: 'windows11arm64', x64: 'windows11' };
|
||||||
|
if (!pages[arch]) {
|
||||||
|
console.error(arch === undefined ? `unsupported host architecture ${process.arch}: pass --arch x64|arm64` : '--arch must be x64 or arm64');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const log = (...a) => console.error('[iso]', ...a);
|
||||||
|
|
||||||
|
function findPlaywright() {
|
||||||
|
if (process.env.PLAYWRIGHT_CORE) return process.env.PLAYWRIGHT_CORE;
|
||||||
|
try { return path.dirname(require.resolve('playwright-core/package.json')); } catch {}
|
||||||
|
console.error('playwright-core not found: run npm ci or set PLAYWRIGHT_CORE');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChrome() {
|
||||||
|
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
|
||||||
|
const root = path.join(os.homedir(), '.cache/ms-playwright');
|
||||||
|
const dirs = fs.existsSync(root) ? fs.readdirSync(root).filter(d => /^chromium-\d+$/.test(d)).sort() : [];
|
||||||
|
if (!dirs.length) { console.error('Chromium not found: set CHROME_PATH or run npx playwright-core install chromium'); process.exit(2); }
|
||||||
|
return path.join(root, dirs.pop(), 'chrome-linux64/chrome');
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { chromium } = require(findPlaywright());
|
||||||
|
const browser = await chromium.launch({ executablePath: findChrome(), headless: true });
|
||||||
|
try {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
locale: 'en-US',
|
||||||
|
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
let api = null;
|
||||||
|
page.on('response', async r => {
|
||||||
|
if (/GetProductDownloadLinks/i.test(r.url())) api = await r.json().catch(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
log('opening', pages[arch]);
|
||||||
|
await page.goto(`https://www.microsoft.com/en-us/software-download/${pages[arch]}`, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await page.click('button:has-text("Accept")', { timeout: 3000 }).catch(() => {});
|
||||||
|
|
||||||
|
// Pick the first real edition (currently the only multi-edition ISO).
|
||||||
|
const edition = await page.$$eval('#product-edition option', o => o.map(x => x.value).find(v => v && v !== 'null'));
|
||||||
|
if (!edition) throw new Error('no edition option found; the page layout may have changed');
|
||||||
|
await page.selectOption('#product-edition', edition);
|
||||||
|
await page.click('#submit-product-edition');
|
||||||
|
|
||||||
|
await page.waitForSelector('#product-languages option:not([value="null"])', { state: 'attached', timeout: 60000 });
|
||||||
|
const langs = await page.$$eval('#product-languages option', o => o.map(x => ({ value: x.value, text: x.text })));
|
||||||
|
const hit = langs.find(l => l.text === lang);
|
||||||
|
if (!hit) throw new Error(`no language "${lang}"; available: ${langs.map(l => l.text).filter(t => t !== 'Choose one').join(', ')}`);
|
||||||
|
await page.selectOption('#product-languages', hit.value);
|
||||||
|
await page.click('#submit-sku');
|
||||||
|
|
||||||
|
await page.waitForSelector('a[href*="prss.microsoft.com"]', { timeout: 60000 });
|
||||||
|
const url = await page.$eval('a[href*="prss.microsoft.com"]', a => a.href);
|
||||||
|
const opts = api && api.ProductDownloadOptions;
|
||||||
|
log(opts ? opts[0].Name : '(API response not captured)', '| expires:', api && api.DownloadExpirationDatetime);
|
||||||
|
console.log(url);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
})().catch(e => { console.error('[iso] failed:', e.message); process.exit(1); });
|
||||||
Generated
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "win-iso",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "win-iso",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "^1.64.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "win-iso",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "get-windows-iso-url.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "^1.64.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user