#!/usr/bin/env bash set -Eeuo pipefail ETFS="boot/etfsboot.com" FB="falling back to manual installation!" EFISYS="efi/microsoft/boot/efisys_noprompt.bin" backup () { local iso="$1" local count=1 local name="unknown" local root="$STORAGE/backups" local file previous failed="" previous=$(readState "base") || return 1 [ -n "$previous" ] && name="${previous%.*}" if ! makeDir "$root"; then error "Failed to create directory \"$root\" !" return 1 fi local folder="$name" local dir="$root/$folder" while [ -d "$dir" ]; do (( count++ )) folder="${name}.${count}" dir="$root/$folder" done if ! makeDir "$dir"; then error "Failed to create directory \"$dir\" !" return 1 fi if [ -f "$iso" ]; then if ! mv -f -- "$iso" "$dir/"; then error "Failed to move \"$iso\" to \"$dir\"." failed="Y" fi fi while IFS= read -r -d '' file; do if ! mv -n -- "$file" "$dir/"; then error "Failed to move \"$file\" to \"$dir\"." failed="Y" fi done < <( find "$STORAGE" -maxdepth 1 -type f \ \( -iname 'data.*' -or -iname 'windows.*' -or -iname '*.rom' -or -iname '*.vars' \) \ -not -iname '*.iso' -print0 ) [ -z "$(ls -A "$dir")" ] && rm -rf "$dir" [ -z "$(ls -A "$root")" ] && rm -rf "$root" [ -n "$failed" ] && return 1 return 0 } skipInstall() { local iso="$1" local method="" local magic byte local boot="$STORAGE/windows.boot" local previous previous=$(readState "base") || return 1 if [ -n "$previous" ]; then if [[ "${STORAGE,,}/${previous,,}" != "${iso,,}" ]]; then if ! hasDisk; then rm -f "$STORAGE/$previous" return 1 fi if [[ "${iso,,}" == "${STORAGE,,}/windows."* ]]; then method="your custom .iso file was changed" else if [[ "${previous,,}" != "windows."* ]]; then method="the VERSION variable was changed" else method="your custom .iso file was removed" if [ -f "$boot" ] && hasData; then info "Detected that $method, will be ignored." return 0 fi fi fi info "Detected that $method, a backup of your previous installation will be saved..." if ! backup "$STORAGE/$previous"; then warn "the backup was incomplete, continuing with installation..." fi return 1 fi fi [ -f "$boot" ] && hasData && return 0 [ ! -f "$iso" ] && return 1 [ ! -s "$iso" ] && return 1 # Check if the ISO was already processed by our script magic=$(dd if="$iso" bs=1 count=1 status=none | tr -d '\000') magic="$(printf '%s' "$magic" | od -A n -t x1 -v | tr -d ' \n')" byte="16" && enabled "$MANUAL" && byte="17" if [[ "$magic" != "$byte" ]]; then info "The ISO will be processed again because the configuration was changed..." return 1 fi return 0 } startInstall() { html "Starting $APP..." if [ -z "$CUSTOM" ]; then local file="${VERSION//\//}.iso" if [[ "${VERSION,,}" == "http"* ]]; then file=$(basename "${VERSION%%\?*}") printf -v file '%b' "${file//%/\\x}" file="${file//[!A-Za-z0-9._-]/_}" else local language language=$(getLanguage "$LANGUAGE" "culture") language="${language%%-*}" if [ -n "$language" ] && [[ "${language,,}" != "en" ]]; then file="${VERSION//\//}_${language,,}.iso" fi fi BOOT="$STORAGE/$file" fi TMP="$STORAGE/tmp" rm -rf "$TMP" skipInstall "$BOOT" && return 1 if hasDisk; then if ! backup ""; then warn "the backup was incomplete, continuing with installation..." fi fi if ! makeDir "$TMP"; then error "Failed to create directory \"$TMP\" !" && exit 50 fi if [ -z "$CUSTOM" ]; then ISO=$(basename "$BOOT") ISO="$TMP/$ISO" if [ -f "$BOOT" ] && [ -s "$BOOT" ]; then mv -f "$BOOT" "$ISO" fi fi rm -f "$BOOT" find "$STORAGE" -maxdepth 1 -type f -iname 'data.*' -not -iname '*.iso' -delete find "$STORAGE" -maxdepth 1 -type f -iname 'windows.*' -not -iname '*.iso' -delete find "$STORAGE" -maxdepth 1 -type f \( -iname '*.rom' -or -iname '*.vars' \) -delete return 0 } checkFreeSpace() { local dir="$1" local size="$2" local size_gb space space_gb size_gb=$(formatBytes "$size") space=$(df --output=avail -B 1 "$dir" | tail -n 1) space_gb=$(formatBytes "$space") if (( size > space )); then error "Not enough free space in $STORAGE, have $space_gb available but need at least $size_gb." return 1 fi return 0 } finishInstall() { local iso="$1" local aborted="$2" local base byte if [ ! -s "$iso" ] || [ ! -f "$iso" ]; then error "Failed to find ISO file: $iso" && return 1 fi if [[ "$iso" == "$STORAGE/"* ]]; then if ! setOwner "$iso"; then warn "failed to set the owner for \"$iso\" !" fi fi if [[ "$aborted" != [Yy1]* ]]; then # Mark ISO as prepared via magic byte byte="16" && enabled "$MANUAL" && byte="17" if ! printf '%b' "\x$byte" | dd of="$iso" bs=1 seek=0 count=1 conv=notrunc status=none; then warn "failed to set magic byte in ISO file: $iso" fi fi local file="$STORAGE/windows.ver" cp -f /etc/version "$file" || return 1 if ! setOwner "$file"; then warn "Failed to set the owner for \"$file\" !" fi if [[ "$iso" == "$STORAGE/"* ]]; then if [[ "$aborted" != [Yy1]* ]] || [ -z "$CUSTOM" ]; then base=$(basename "$iso") writeState "base" "$base" || return 1 fi fi if [[ "${PLATFORM,,}" == "x64" ]]; then if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then writeState "mode" "$BOOT_MODE" || return 1 if [[ "${MACHINE,,}" != "q35" ]]; then writeState "old" "$MACHINE" || return 1 fi else # Enable secure boot + TPM on manual installs as Win11 requires if enabled "$MANUAL" || [[ "$aborted" == [Yy1]* ]]; then if [[ "${DETECTED,,}" == "win11"* ]]; then BOOT_MODE="windows_secure" writeState "mode" "$BOOT_MODE" || return 1 fi fi # Enable secure boot on multi-socket systems to workaround freeze if [ -n "$SOCKETS" ] && [[ "$SOCKETS" != "1" ]]; then BOOT_MODE="windows_secure" writeState "mode" "$BOOT_MODE" || return 1 fi fi fi if [ -n "${ARGS:-}" ]; then ARGUMENTS="$ARGS ${ARGUMENTS:-}" writeState "args" "$ARGS" || return 1 fi if [ -n "${VGA:-}" ] && [[ "${VGA:-}" != "virtio"* ]]; then writeState "vga" "$VGA" || return 1 fi if [ -n "${USB:-}" ] && [[ "${USB:-}" != "qemu-xhci"* ]]; then writeState "usb" "$USB" || return 1 fi if [ -n "${DISK_TYPE:-}" ] && [[ "${DISK_TYPE:-}" != "scsi" ]]; then writeState "type" "$DISK_TYPE" || return 1 fi if [ -n "${ADAPTER:-}" ] && [[ "${ADAPTER:-}" != "virtio-net-pci" ]]; then writeState "net" "$ADAPTER" || return 1 fi if [ -n "${SOUND:-}" ] && [[ "${SOUND:-}" != "intel-hda" ]]; then writeState "sound" "$SOUND" || return 1 fi rm -rf "$TMP" return 0 } abortInstall() { local dir="$1" local iso="$2" local efi [[ "${iso,,}" == *".esd" ]] && exit 60 enabled "${UNPACK:-}" && exit 60 efi=$(find "$dir" -maxdepth 1 -type d -iname efi -print -quit) if [ -z "$efi" ]; then [[ "${PLATFORM,,}" == "x64" ]] && BOOT_MODE="windows_legacy" fi if [ -n "$CUSTOM" ]; then BOOT="$iso" REMOVE="N" else if [[ "$iso" != "$BOOT" ]]; then if ! mv -f "$iso" "$BOOT"; then error "Failed to move ISO file: $iso" && return 1 fi fi fi finishInstall "$BOOT" "Y" && return 0 return 1 } findFile() { local dir file base local fname="$1" local boot="$STORAGE/windows.boot" dir=$(find / -maxdepth 1 -type d -iname "$fname" -print -quit) [ ! -d "$dir" ] && dir=$(find "$STORAGE" -maxdepth 1 -type d -iname "$fname" -print -quit) if [ -d "$dir" ]; then if ! hasData || [ ! -f "$boot" ]; then error "The bind $dir maps to a file that does not exist!" && return 1 fi fi file=$(find / -maxdepth 1 -type f -iname "$fname" -print -quit) [ ! -s "$file" ] && file=$(find "$STORAGE" -maxdepth 1 -type f -iname "$fname" -print -quit) if [ ! -s "$file" ] && [[ "${VERSION,,}" != "http"* ]]; then base=$(basename "$VERSION") file="$STORAGE/$base" fi if [ ! -f "$file" ] || [ ! -s "$file" ]; then return 0 fi local size size="$(stat -c%s "$file")" [ -z "$size" ] || [[ "$size" == "0" ]] && return 0 ISO="$file" CUSTOM="$file" BOOT="$STORAGE/windows.$size.iso" return 0 } detectCustom() { CUSTOM="" ! findFile "custom.iso" && return 1 [ -n "$CUSTOM" ] && return 0 ! findFile "boot.iso" && return 1 [ -n "$CUSTOM" ] && return 0 return 0 } getEsdField() { local list="$1" local index="$2" sed -n "${index}p" <<< "$list" | tr -cd '0-9' return 0 } extractESD() { local iso="$1" local dir="$2" local version="$3" local desc="$4" local msg ret index local minSize freeSpace bootPad installPad local info count totals links local bootTotal bootLinks bootSize local wimTotal wimLinks wimSize local installSize local bootWim installWim local edition imgEdition minSize=100000000 freeSpace=9606127360 bootPad=60000000 installPad=3000000 msg="Extracting $desc bootdisk" info "$msg..." && html "$msg..." if [ "$(stat -c%s "$iso")" -lt "$minSize" ]; then error "Invalid ESD file: Size is smaller than 100 MB" return 1 fi rm -rf "$dir" if ! makeDir "$dir"; then error "Failed to create directory \"$dir\" !" return 1 fi checkFreeSpace "$dir" "$freeSpace" || return 1 info=$(wimlib-imagex info "$iso") || { error "Cannot read ESD file information!" return 1 } count=$(awk '/Image Count:/ {print $3}' <<< "$info") if [[ ! "$count" =~ ^[0-9]+$ ]]; then error "Cannot read the image count in ESD file!" return 1 fi if (( count < 3 )); then error "Invalid ESD file: expected at least 3 images, found $count." return 1 fi totals=$(grep "Total Bytes:" <<< "$info" || true) links=$(grep "Hard Link Bytes:" <<< "$info" || true) bootTotal=$(getEsdField "$totals" 1) bootLinks=$(getEsdField "$links" 1) if [[ ! "$bootTotal" =~ ^[0-9]+$ ]] || [[ ! "$bootLinks" =~ ^[0-9]+$ ]]; then error "Cannot read bootdisk size from ESD file!" return 1 fi bootSize=$(( bootTotal - bootLinks )) wimTotal=$(getEsdField "$totals" 3) wimLinks=$(getEsdField "$links" 3) if [[ ! "$wimTotal" =~ ^[0-9]+$ ]] || [[ ! "$wimLinks" =~ ^[0-9]+$ ]]; then error "Cannot read boot.wim size from ESD file!" return 1 fi wimSize=$(( wimTotal - wimLinks + bootPad )) /run/progress.sh "$dir" "$bootSize" "$msg ([P])..." & index="1" wimlib-imagex apply "$iso" "$index" "$dir" --quiet 2>/dev/null || { ret=$? fKill "progress.sh" error "Extracting $desc bootdisk failed ($ret)" return 1 } fKill "progress.sh" bootWim="$dir/sources/boot.wim" installWim="$dir/sources/install.wim" msg="Extracting $desc environment" info "$msg..." && html "$msg..." index="2" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --quiet || { ret=$? fKill "progress.sh" error "Adding WinPE failed ($ret)" return 1 } fKill "progress.sh" msg="Extracting $desc setup" info "$msg..." index="3" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --boot --quiet || { ret=$? fKill "progress.sh" error "Adding Windows Setup failed ($ret)" return 1 } fKill "progress.sh" if [[ "${PLATFORM,,}" == "x64" ]]; then LABEL="CCCOMA_X64FRE_EN-US_DV9" else LABEL="CPBA_A64FRE_EN-US_DV9" fi msg="Extracting $desc image" info "$msg..." && html "$msg..." edition=$(getCatalog "$version" "name") if [ -z "$edition" ]; then error "Invalid VERSION specified, value \"$version\" is not recognized!" return 1 fi for (( index=4; index<=count; index++ )); do imgEdition=$(wimlib-imagex info "$iso" "$index" | grep '^Description:' | sed 's/Description:[ \t]*//') [[ "${imgEdition,,}" != "${edition,,}" ]] && continue installSize=$(stat -c%s "$iso") installSize=$(( installSize + installPad )) /run/progress.sh "$installWim" "$installSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$installWim" --compress=LZMS --chunk-size 128K --quiet || { ret=$? fKill "progress.sh" error "Addition of $index to the $desc image failed ($ret)" return 1 } fKill "progress.sh" return 0 done fKill "progress.sh" error "Failed to find product '$edition' in install.wim!" return 1 } extractImage() { local iso="$1" local dir="$2" local version="$3" local desc="local ISO" local file size if [ -z "$CUSTOM" ]; then desc="downloaded ISO" if [[ "$version" != "http"* ]]; then desc=$(printVersion "$version" "$desc") fi fi if [[ "${iso,,}" == *".esd" ]]; then extractESD "$iso" "$dir" "$version" "$desc" && return 0 return 1 fi local msg="Extracting $desc image" info "$msg..." && html "$msg..." rm -rf "$dir" if ! makeDir "$dir"; then error "Failed to create directory \"$dir\" !" && return 1 fi size=$(stat -c%s "$iso") if (( size < 100000000 )); then error "Invalid ISO file: Size is smaller than 100 MB" && return 1 fi checkFreeSpace "$dir" "$size" || return 1 rm -rf "$dir" /run/progress.sh "$dir" "$size" "$msg ([P])..." & if ! 7z x "$iso" -o"$dir" > /dev/null; then fKill "progress.sh" error "Failed to extract ISO file: $iso" && return 1 fi fKill "progress.sh" if ! enabled "${UNPACK:-}"; then LABEL=$(isoinfo -d -i "$iso" | sed -n 's/Volume id: //p') || LABEL="" else file=$(find "$dir" -maxdepth 1 -type f -iname "*.iso" -print -quit) if [ -z "$file" ]; then error "Failed to find any .iso file in archive!" && return 1 fi if ! 7z x "$file" -o"$dir" > /dev/null; then error "Failed to extract archive!" && return 1 fi LABEL=$(isoinfo -d -i "$file" | sed -n 's/Volume id: //p') || LABEL="" rm -f "$file" || warn "Failed to remove temporary ISO file: $file" fi return 0 } getPlatform() { local xml="$1" local tag="ARCH" local platform="x64" local arch arch=$(sed -n "/$tag/{s/.*<$tag>\(.*\)<\/$tag>.*/\1/;p}" <<< "$xml") case "${arch,,}" in "0" ) platform="x86" ;; "9" ) platform="x64" ;; "12" ) platform="arm64" ;; esac echo "$platform" return 0 } checkPlatform() { local xml="$1" local platform compat platform=$(getPlatform "$xml") case "${platform,,}" in "x86" ) compat="x64" ;; "x64" ) compat="$platform" ;; "arm64" ) compat="$platform" ;; * ) compat="${PLATFORM,,}" ;; esac [[ "${compat,,}" == "${PLATFORM,,}" ]] && return 0 error "You cannot boot ${platform^^} images on a $PLATFORM CPU!" return 1 } hasVersion() { local id="$1" local tag="$2" local xml="$3" local edition [ ! -f "/run/assets/$id.xml" ] && return 1 edition=$(printEdition "$id" "") [ -z "$edition" ] && return 1 [[ "${xml,,}" != *"<${tag,,}>${edition,,}"* ]] && return 1 return 0 } selectVersion() { local tag="$1" local xml="$2" local platform="$3" local id name prefer name=$(sed -n "/$tag/{s/.*<$tag>\(.*\)<\/$tag>.*/\1/;p}" <<< "$xml") [[ "$name" == *"Operating System"* ]] && name="" [ -z "$name" ] && return 0 id=$(fromName "$name" "$platform") [ -z "$id" ] && warn "Unknown ${tag,,}: '$name'" && return 0 if [ -n "$EDITION" ] && [[ "$id" != win20* ]]; then case "${EDITION,,}" in "pro" | "professional" | "business" ) prefer="$id" ;; * ) prefer="$id-${EDITION,,}" ;; esac if hasVersion "$prefer" "$tag" "$xml"; then echo "$prefer" return 0 fi warn "Edition '$EDITION' is not supported by this image, using automatic selection instead." fi prefer="$id-enterprise" hasVersion "$prefer" "$tag" "$xml" && echo "$prefer" && return 0 prefer="$id-ultimate" hasVersion "$prefer" "$tag" "$xml" && echo "$prefer" && return 0 prefer="$id" hasVersion "$prefer" "$tag" "$xml" && echo "$prefer" && return 0 prefer=$(getVersion "$name" "$platform") echo "$prefer" return 0 } detectVersion() { local xml="$1" local id platform platform=$(getPlatform "$xml") id=$(selectVersion "DISPLAYNAME" "$xml" "$platform") [ -z "$id" ] && id=$(selectVersion "PRODUCTNAME" "$xml" "$platform") [ -z "$id" ] && id=$(selectVersion "NAME" "$xml" "$platform") echo "$id" return 0 } detectLanguage() { local xml="$1" local lang="" if [[ "$xml" == *"LANGUAGE>"* ]]; then lang="${xml#*LANGUAGE>}" lang="${lang%%<*}" else if [[ "$xml" == *"FALLBACK>"* ]]; then lang="${xml#*FALLBACK>}" lang="${lang%%<*}" fi fi if [ -z "$lang" ]; then warn "Language could not be detected from ISO!" && return 0 fi local culture culture=$(getLanguage "$lang" "culture") [ -n "$culture" ] && LANGUAGE="$lang" && return 0 warn "Invalid language detected: \"$lang\"" return 0 } setXML() { local file="/custom.xml" CUSTOM_XML="" if [ -d "$file" ]; then error "The bind $file maps to a file that does not exist!" && exit 67 fi [ ! -f "$file" ] || [ ! -s "$file" ] && file="$STORAGE/custom.xml" [ ! -f "$file" ] || [ ! -s "$file" ] && file="/run/assets/custom.xml" [ ! -f "$file" ] || [ ! -s "$file" ] && file="$1" [ ! -f "$file" ] || [ ! -s "$file" ] && file="/run/assets/$DETECTED.xml" [ ! -f "$file" ] || [ ! -s "$file" ] && return 1 case "$file" in "/custom.xml" | "$STORAGE/custom.xml" ) CUSTOM_XML="Y" ;; esac XML="$file" return 0 } detectImage() { local dir="$1" local version="$2" local desc msg language XML="" if [ -z "$DETECTED" ] && [ -z "$CUSTOM" ]; then [[ "${version,,}" != "http"* ]] && DETECTED="$version" fi if [ -n "$DETECTED" ]; then skipVersion "${DETECTED,,}" && return 0 if ! setXML "" && ! enabled "$MANUAL"; then MANUAL="Y" desc=$(printEdition "$DETECTED" "this version") warn "the answer file for $desc was not found ($DETECTED.xml), $FB." fi return 0 fi info "Detecting version from ISO image..." if detectLegacy "$dir"; then desc=$(printEdition "$DETECTED" "$DETECTED") info "Detected: $desc" return 0 fi local src wim info src=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) if [ ! -d "$src" ]; then warn "failed to locate 'sources' folder in ISO image, $FB" && return 1 fi wim=$(find "$src" -maxdepth 1 -type f \( -iname install.wim -or -iname install.esd \) -print -quit) if [ ! -f "$wim" ]; then warn "failed to locate 'install.wim' or 'install.esd' in ISO image, $FB" && return 1 fi if ! info=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8); then warn "failed to read Windows image information, $FB" return 1 fi checkPlatform "$info" || exit 67 DETECTED=$(detectVersion "$info") if [ -z "$DETECTED" ]; then msg="Failed to determine Windows version from image" if setXML "" || enabled "$MANUAL"; then info "${msg}!" else MANUAL="Y" warn "${msg}, $FB." fi return 0 fi desc=$(printEdition "$DETECTED" "$DETECTED") detectLanguage "$info" if [[ "${LANGUAGE,,}" != "en" && "${LANGUAGE,,}" != "en-"* ]]; then language=$(getLanguage "$LANGUAGE" "desc") desc+=" ($language)" fi info "Detected: $desc" setXML "" && return 0 if [[ "$DETECTED" == "win81x86"* || "$DETECTED" == "win10x86"* ]]; then error "The 32-bit version of $desc is not supported!" && return 1 fi msg="the answer file for $desc was not found ($DETECTED.xml)" local fallback="/run/assets/${DETECTED%%-*}.xml" if setXML "$fallback" || enabled "$MANUAL"; then ! enabled "$MANUAL" && warn "${msg}." else MANUAL="Y" warn "${msg}, $FB." fi return 0 } prepareImage() { local iso="$1" local dir="$2" local desc missing desc=$(printVersion "$DETECTED" "$DETECTED") setMachine "$DETECTED" "$iso" "$dir" "$desc" || return 1 skipVersion "$DETECTED" && return 0 if [[ "${BOOT_MODE,,}" != "windows_legacy" ]]; then [ -f "$dir/$ETFS" ] && [ -f "$dir/$EFISYS" ] && return 0 missing=$(basename "$dir/$EFISYS") [ ! -f "$dir/$ETFS" ] && missing=$(basename "$dir/$ETFS") error "Failed to locate file \"${missing,,}\" in ISO image!" return 1 fi prepareLegacy "$iso" "$dir" "$desc" && return 0 error "Failed to extract boot image from ISO image!" return 1 } escapeXMLSed() { local s s=$(escapeXML "$1") || return 1 s=${s//\\/\\\\} s=${s//&/\\&} s=${s//|/\\|} printf '%s' "$s" return 0 } updateDomain() { local asset="$1" local domain account auth pass local pw cred_domain ou tmp result domain=$(escapeXML "$2") || return 1 account=$(escapeXML "$3") || return 1 auth=$(escapeXML "$4") || return 1 pass=$(escapeXML "$5") || return 1 pw="$6" ou=$(escapeXML "$7") || return 1 cred_domain="$domain" case "$4" in *@* ) cred_domain="" ;; esac grep -Eq 'Microsoft-Windows-UnattendedJoin|])' "$asset" && return 1 tmp=$(mktemp -d) || return 1 result="$tmp/answer.xml" if ! DOMAIN_XML="$domain" ACCOUNT_XML="$account" \ AUTH_XML="$auth" PASS_XML="$pass" \ CRED_DOMAIN="$cred_domain" PW="$pw" OU_XML="$ou" \ awk ' /]*pass="specialize"[^>]*>/ { section = "specialize" } /]*pass="oobeSystem"[^>]*>/ { section = "oobeSystem" } section == "oobeSystem" && /])/ { in_accounts = 1 } section == "oobeSystem" && /])/ { in_autologon = 1 } section == "oobeSystem" && in_accounts && !accounts_added && /])/ { print " \n" \ " \n" \ " \n" \ " " ENVIRON["ACCOUNT_XML"] "\n" \ " Administrators\n" \ " \n" \ " " ENVIRON["DOMAIN_XML"] "\n" \ " \n" \ " " accounts_added = 1 } section == "oobeSystem" && in_autologon && /^[[:space:]]*.*<\/Username>[[:space:]]*$/ { print " " ENVIRON["ACCOUNT_XML"] "\n" \ " " ENVIRON["DOMAIN_XML"] "" autologon_added = 1 next } section == "oobeSystem" && in_autologon && /^[[:space:]]*])/ { next } section == "oobeSystem" && in_autologon && /^[[:space:]]*.*<\/Value>[[:space:]]*$/ { print " " ENVIRON["PW"] "" password_added = 1 next } section == "specialize" && !join_added && /^[[:space:]]*<\/settings>[[:space:]]*$/ { print " \n" \ " \n" \ " " if (ENVIRON["CRED_DOMAIN"] != "") { print " " ENVIRON["CRED_DOMAIN"] "" } print " " ENVIRON["AUTH_XML"] "\n" \ " " ENVIRON["PASS_XML"] "\n" \ " \n" \ " " ENVIRON["DOMAIN_XML"] "" if (ENVIRON["OU_XML"] != "") { print " " ENVIRON["OU_XML"] "" } print " \n" \ " " join_added = 1 } { print } section == "oobeSystem" && /<\/AutoLogon>/ { in_autologon = 0 } section == "oobeSystem" && /<\/UserAccounts>/ { in_accounts = 0 } /^[[:space:]]*<\/settings>[[:space:]]*$/ { section = "" } END { exit !(join_added && accounts_added && autologon_added && password_added) } ' "$asset" > "$result" || ! mv -f "$result" "$asset"; then rm -rf "$tmp" || true return 1 fi rm -rf "$tmp" || return 1 return 0 } validateLocalUsername() { local value="$1" [ -z "$value" ] && return 0 if [ "${#value}" -gt 20 ]; then error "The USERNAME variable cannot contain more than 20 characters!" return 1 fi if [[ "$value" =~ [[:cntrl:]] ]]; then error "The USERNAME variable cannot contain control characters!" return 1 fi case "$value" in *'"'* | *'/'* | *\\* | *'['* | *']'* | *':'* | *';'* | *'|'* | *'='* | *','* | *'+'* | *'*'* | *'?'* | *'<'* | *'>'* | *'%'* | *'@'* ) error "The USERNAME variable contains characters that are not supported by Windows local accounts!" return 1 ;; esac if [[ "$value" == *"." ]]; then error "The USERNAME variable cannot end with a period!" return 1 fi if [[ "$value" =~ ^[.[:space:]]+$ ]]; then error "The USERNAME variable cannot consist only of spaces or periods!" return 1 fi case "${value^^}" in "NONE" ) error "The USERNAME value \"NONE\" is reserved by Windows!" return 1 ;; "ADMINISTRATOR" | "GUEST" | "DEFAULTACCOUNT" | "WDAGUTILITYACCOUNT" | "WSIACCOUNT" ) error "The USERNAME value \"$value\" is reserved for a built-in Windows account!" return 1 ;; esac return 0 } validateDomainName() { local value="$1" local name="${2:-DOMAIN}" if [ -z "$value" ]; then error "The $name variable must contain a valid domain name!" return 1 fi if [[ "$value" == *"://"* ]]; then error "The $name variable must contain a domain name, not a URL!" return 1 fi if [ "${#value}" -gt 255 ] || [[ "$value" =~ [[:cntrl:]] ]] || [[ "$value" =~ [[:space:]] ]] || [[ ! "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$ ]]; then error "The $name variable does not contain a valid domain name!" return 1 fi return 0 } validateDomainUsername() { local value="$1" if [ -z "$value" ]; then error "The USERNAME variable does not contain a valid domain account name!" return 1 fi if [ "${#value}" -gt 256 ]; then error "The USERNAME variable cannot contain more than 256 characters for a domain account!" return 1 fi if [[ "$value" =~ [[:cntrl:]] ]]; then error "The USERNAME variable cannot contain control characters!" return 1 fi case "$value" in *'"'* | *'/'* | *\\* | *'['* | *']'* | *':'* | *';'* | *'|'* | *'='* | *','* | *'+'* | *'*'* | *'?'* | *'<'* | *'>'* | *'%'* | *'@'* ) error "The domain account name contains characters that are not supported by Windows unattended setup!" return 1 ;; esac if [[ "$value" == *"." ]]; then error "The USERNAME variable cannot end with a period!" return 1 fi if [[ "$value" =~ ^[.[:space:]]+$ ]]; then error "The USERNAME variable cannot consist only of spaces or periods!" return 1 fi if [[ "${value^^}" == "NONE" ]]; then error "The USERNAME value \"NONE\" is reserved by Windows!" return 1 fi return 0 } updateWorkgroup() { local asset="$1" local workgroup arch tmp result workgroup=$(escapeXML "$2") || return 1 arch=$(sed -n -E '0,/processorArchitecture="/s/.*processorArchitecture="([^"]+)".*/\1/p' "$asset") || return 1 [ -z "$arch" ] && return 1 grep -q 'Microsoft-Windows-UnattendedJoin' "$asset" && return 1 tmp=$(mktemp -d) || return 1 result="$tmp/answer.xml" if ! WORKGROUP_XML="$workgroup" ARCH_XML="$arch" awk ' /]*pass="specialize"[^>]*>/ { section = "specialize" } section == "specialize" && !workgroup_added && /^[[:space:]]*<\/settings>[[:space:]]*$/ { print " \n" \ " \n" \ " " ENVIRON["WORKGROUP_XML"] "\n" \ " \n" \ " " workgroup_added = 1 } { print } /^[[:space:]]*<\/settings>[[:space:]]*$/ { section = "" } END { exit !workgroup_added } ' "$asset" > "$result" || ! mv -f "$result" "$asset"; then rm -rf "$tmp" || true return 1 fi rm -rf "$tmp" || return 1 return 0 } updateXML() { local asset="$1" local language="$2" local app value culture region keyboard edition key local user user_xml auth_user admin pass pw local domain qualifier host workgroup [ -z "$WIDTH" ] && WIDTH="1280" [ -z "$HEIGHT" ] && HEIGHT="720" validateResolution "WIDTH" "$WIDTH" 320 || return 1 validateResolution "HEIGHT" "$HEIGHT" 200 || return 1 validateMembership || return 1 validateComputerName "$HOST" || return 1 validateProductKey "$KEY" || return 1 validatePassword "$PASSWORD" || return 1 app=$(escapeXMLSed "$APP for $ENGINE") || return 1 host=$(escapeXMLSed "$HOST") || return 1 sed -i "s|>Windows for Docker<|>$app<|g" "$asset" || return 1 sed -i -E "s|[^<]*|$host|g" "$asset" || return 1 sed -i -E "s|[^<]*|$HEIGHT|g" "$asset" || return 1 sed -i -E "s|[^<]*|$WIDTH|g" "$asset" || return 1 culture=$(getLanguage "$language" "culture") || return 1 if [ -n "$culture" ] && [[ "${culture,,}" != "en-us" ]]; then value=$(escapeXMLSed "$culture") || return 1 sed -i "s|en-US|$value|g" "$asset" || return 1 fi region="$REGION" [ -z "$region" ] && region="$culture" if [ -n "$region" ] && [[ "${region,,}" != "en-us" ]]; then value=$(escapeXMLSed "$region") || return 1 sed -i "s|en-US|$value|g" "$asset" || return 1 sed -i "s|en-US|$value|g" "$asset" || return 1 fi keyboard="$KEYBOARD" [ -z "$keyboard" ] && keyboard="$culture" if [ -n "$keyboard" ] && [[ "${keyboard,,}" != "en-us" ]]; then value=$(escapeXMLSed "$keyboard") || return 1 sed -i "s|en-US|$value|g" "$asset" || return 1 sed -i "s|0409:00000409|$value|g" "$asset" || return 1 fi domain="$DOMAIN" workgroup="$WORKGROUP" case "${DETECTED,,}" in "win10x64"* | "win11x64"* ) ;; * ) domain="" ;; esac if [ -n "$domain" ]; then if [ -z "$USERNAME" ]; then error "The USERNAME variable must be specified when joining a domain!" return 1 fi if [ -z "$PASSWORD" ]; then error "The PASSWORD variable must be specified when joining a domain!" return 1 fi validateDomainName "$domain" || return 1 auth_user="$USERNAME" qualifier="" if [[ "$auth_user" == *\\* ]]; then error "The USERNAME variable must use either \"user\" or \"user@domain\" format!" return 1 fi case "$auth_user" in *@* ) user="${auth_user%%@*}" qualifier="${auth_user#*@}" if [ -z "$user" ] || [ -z "$qualifier" ] || [[ "$qualifier" == *@* ]]; then error "The USERNAME variable does not contain a valid domain account name!" return 1 fi validateDomainName "$qualifier" "USERNAME" || return 1 if [[ "${qualifier,,}" != "${domain,,}" ]]; then error "The domain in the USERNAME variable must match the DOMAIN variable!" return 1 fi ;; * ) user="$auth_user" ;; esac validateDomainUsername "$user" || return 1 if [[ "${user,,}" == "docker" ]]; then error "The USERNAME variable must be changed from its default value when joining a domain!" return 1 fi if [[ "$PASSWORD" == "admin" ]]; then error "The PASSWORD variable must be changed from its default value when joining a domain!" return 1 fi else user="$USERNAME" validateLocalUsername "$user" || return 1 if [ -n "$user" ]; then user_xml=$(escapeXMLSed "$user") || return 1 sed -i "s|-name \"Docker\"|-name \"\$env:USERNAME\"|g" "$asset" || return 1 sed -i 's|where name="Docker"|where name="%USERNAME%"|g' "$asset" || return 1 sed -i "s|Docker|$user_xml|g" "$asset" || return 1 sed -i "s|Docker|$user_xml|g" "$asset" || return 1 sed -i "s|Docker|$user_xml|g" "$asset" || return 1 fi fi if [ -n "$domain" ]; then pass="admin" else [ -n "$PASSWORD" ] && pass="$PASSWORD" || pass="admin" fi pw=$(printf '%s' "${pass}Password" | iconv -f utf-8 -t utf-16le | base64 -w 0) || return 1 admin=$(printf '%s' "${pass}AdministratorPassword" | iconv -f utf-8 -t utf-16le | base64 -w 0) || return 1 sed -i -z -E "s#([[:space:]]*|>[^<]*)#\1>$pw#g" "$asset" || return 1 sed -i -z -E "s#([[:space:]]*|>[^<]*)#\1>$admin#g" "$asset" || return 1 sed -i -E "s|[^<]*</PlainText>|<PlainText>false</PlainText>|g" "$asset" || return 1 if [ -n "$domain" ]; then pw=$(printf '%s' "${PASSWORD}Password" | iconv -f utf-8 -t utf-16le | base64 -w 0) || return 1 if ! updateDomain "$asset" "$domain" "$user" \ "$auth_user" "$PASSWORD" "$pw" "$DOMAIN_OU"; then error "Failed to add domain configuration to answer file!" return 1 fi elif [ -n "$workgroup" ]; then if ! updateWorkgroup "$asset" "$workgroup"; then error "Failed to add workgroup configuration to answer file!" return 1 fi fi if disabled "$AUTOLOGIN"; then sed -i -E '/^[[:space:]]*<AutoLogon([[:space:]>])/,/^[[:space:]]*<\/AutoLogon>[[:space:]]*$/d' "$asset" || return 1 fi if [ -n "$EDITION" ]; then case "${EDITION,,}" in "core" ) edition="STANDARDCORE" ;; * ) edition="${EDITION^^}" ;; esac edition=$(escapeXMLSed "$edition") || return 1 sed -i "s|SERVERSTANDARD</Value>|SERVER$edition</Value>|g" "$asset" || return 1 fi if [ -n "$KEY" ]; then key=$(escapeXMLSed "$KEY") || return 1 sed -i -E '/^[[:space:]]*<ProductKey>[[:space:]]*$/,/^[[:space:]]*<\/ProductKey>[[:space:]]*$/d' "$asset" || return 1 sed -i -E "s|<ProductKey>[^<]*</ProductKey>|<ProductKey>$key</ProductKey>|g" "$asset" || return 1 sed -i "s|</UserData>| <ProductKey>\n <Key>$key</Key>\n <WillShowUI>OnError</WillShowUI>\n </ProductKey>\n </UserData>|g" "$asset" || return 1 fi if ! xmllint --nonet --noout "$asset"; then error "The generated answer file is not valid XML!" return 1 fi return 0 } addDriver() { local id="$1" local path="$2" local target="$3" local driver="$4" local desc="" local folder="" if [ -z "$id" ]; then warn "no Windows version specified for \"$driver\" driver!" && return 0 fi case "${id,,}" in "win7x86"* ) folder="w7/x86" ;; "win7x64"* ) folder="w7/amd64" ;; "win81x64"* ) folder="w8.1/amd64" ;; "win10x64"* ) folder="w10/amd64" ;; "win11x64"* ) folder="w11/amd64" ;; "win2025"* ) folder="2k25/amd64" ;; "win2022"* ) folder="2k22/amd64" ;; "win2019"* ) folder="2k19/amd64" ;; "win2016"* ) folder="2k16/amd64" ;; "win2012"* ) folder="2k12R2/amd64" ;; "win2008"* ) folder="2k8R2/amd64" ;; "win10arm64"* ) folder="w10/ARM64" ;; "win11arm64"* ) folder="w11/ARM64" ;; "winvistax86"* ) folder="2k8/x86" ;; "winvistax64"* ) folder="2k8/amd64" ;; esac if [ -z "$folder" ]; then desc=$(printVersion "$id" "$id") if [[ "${id,,}" != *"x86"* ]]; then warn "no \"$driver\" driver available for \"$desc\" !" && return 0 else warn "no \"$driver\" driver available for the 32-bit version of \"$desc\" !" && return 0 fi fi [ ! -d "$path/$driver/$folder" ] && return 0 case "${id,,}" in "winvista"* ) [[ "${driver,,}" == "viorng" ]] && return 0 ;; esac local dest="$path/$target/$driver" mkdir -p "$dest" || return 1 cp -Lr "$path/$driver/$folder/." "$dest" || return 1 return 0 } addDrivers() { local src="$1" local tmp="$2" local file="$3" local index="$4" local version="$5" local drivers="$tmp/drivers" rm -rf "$drivers" mkdir -p "$drivers" local msg="Adding drivers to image..." info "$msg" && html "$msg" if [ -z "$version" ]; then version="win11x64" warn "Windows version unknown, falling back to Windows 11 drivers..." fi if ! bsdtar -xf /var/drivers.txz -C "$drivers"; then error "Failed to extract drivers from archive!" && return 1 fi local target="\$WinPEDriver\$" local dest="$drivers/$target" mkdir -p "$dest" || return 1 wimlib-imagex update "$file" "$index" --command "delete --force --recursive /$target" >/dev/null || true local driver local driver_list=( qxl viofs sriov smbus qxldod viorng viostor viomem NetKVM Balloon vioscsi pvpanic vioinput viogpudo vioserial qemupciserial ) for driver in "${driver_list[@]}"; do addDriver "$version" "$drivers" "$target" "$driver" || return 1 done local dst="$src/\$OEM\$/\$\$/Drivers" mkdir -p "$dst" || return 1 cp -Lr "$dest/." "$dst" || return 1 case "${version,,}" in "win11x64"* | "win2025"* ) # Workaround Virtio GPU driver bug rm -rf "$dest/viogpudo" ;; esac if ! wimlib-imagex update "$file" "$index" --command "add $dest /$target" >/dev/null; then return 1 fi rm -rf "$drivers" return 0 } updateImage() { local dir="$1" local asset="$2" local language="$3" local tmp="/tmp/install" local xml="autounattend.xml" local bak="${xml//.xml/.org}" local dat="${xml//.xml/.dat}" local desc path src wim name info idx skipVersion "${DETECTED,,}" && return 0 if [ ! -s "$asset" ] || [ ! -f "$asset" ]; then asset="" if ! enabled "$MANUAL"; then MANUAL="Y" warn "no answer file provided, $FB." fi fi rm -rf "$tmp" || return 1 mkdir -p "$tmp" || return 1 src=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) || return 1 if [ ! -d "$src" ]; then error "failed to locate 'sources' folder in ISO image, $FB" return 1 fi wim=$(find "$src" -maxdepth 1 -type f \( -iname boot.wim -or -iname boot.esd \) -print -quit) || return 1 if [ ! -f "$wim" ]; then error "failed to locate 'boot.wim' or 'boot.esd' in ISO image, $FB" return 1 fi idx="1" if ! info=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8); then warn "failed to read boot image information, $FB" MANUAL="Y" info="" fi if [[ "${info^^}" == *"<IMAGE INDEX=\"2\">"* ]]; then idx="2" fi if ! addDrivers "$src" "$tmp" "$wim" "$idx" "$DETECTED"; then error "Failed to add drivers to image!" fi if ! addFolder "$src"; then error "Failed to add OEM folder to image!" fi if wimlib-imagex extract "$wim" "$idx" "/$xml" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex extract "$wim" "$idx" "/$dat" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex extract "$wim" "$idx" "/$bak" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex update "$wim" "$idx" --command "rename /$xml /$bak" > /dev/null; then warn "failed to backup original answer file ($xml)." fi fi fi fi if ! enabled "$MANUAL"; then name=$(basename "$asset") || return 1 local answer="$tmp/$name" info "Adding $name for automatic installation..." if ! cp "$asset" "$answer"; then error "Failed to copy answer file to $answer." return 1 fi local fallback="/run/assets/${DETECTED%%-*}.xml" if [[ "$DETECTED" != win20* && "$asset" == "$fallback" && "$asset" != "/run/assets/$DETECTED.xml" ]]; then if ! sed -i \ -e '/<InstallFrom>.*<\/InstallFrom>/d' \ -e '/<ProductKey>.*<\/ProductKey>/d' \ -e '/<InstallFrom>/,/<\/InstallFrom>/d' \ -e '/<ProductKey>/,/<\/ProductKey>/d' \ "$answer"; then error "Failed to make answer file edition-neutral: $answer" return 1 fi fi if [ -n "${CUSTOM_XML:-}" ]; then if ! xmllint --nonet --noout "$answer"; then error "The custom answer file is not valid XML!" return 1 fi else if ! updateXML "$answer" "$language"; then error "Failed to update answer file: $answer" return 1 fi fi if ! wimlib-imagex update "$wim" "$idx" --command "add $answer /$xml" > /dev/null; then MANUAL="Y" warn "failed to add answer file ($name) to ISO image, $FB" else wimlib-imagex update "$wim" "$idx" --command "add $answer /$dat" > /dev/null || true fi fi if enabled "$MANUAL"; then wimlib-imagex update "$wim" "$idx" --command "delete --force /$xml" > /dev/null || true if wimlib-imagex extract "$wim" "$idx" "/$bak" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex update "$wim" "$idx" --command "add $tmp/$bak /$xml" > /dev/null; then warn "failed to restore original answer file ($bak)." fi fi fi name="$xml" enabled "$MANUAL" && name="$bak" path=$(find "$dir" -maxdepth 1 -type f -iname "$name" -print -quit) || return 1 if [ -f "$path" ]; then if ! enabled "$MANUAL"; then if ! mv -f "$path" "${path%.*}.org"; then error "Failed to rename answer file: $path" return 1 fi else if ! mv -f "$path" "${path%.*}.xml"; then error "Failed to rename answer file: $path" return 1 fi fi fi rm -rf "$tmp" || return 1 return 0 } removeImage() { local iso="$1" [ ! -f "$iso" ] && return 0 [ -n "$CUSTOM" ] && return 0 rm -f "$iso" 2> /dev/null || warn "failed to remove $iso !" return 0 } buildImage() { local dir="$1" local failed="" local cat="BOOT.CAT" local log="/run/shm/iso.log" local base size desc if [ -f "$BOOT" ]; then error "File $BOOT does already exist?!" && return 1 fi base=$(basename "$BOOT") local out="$TMP/${base%.*}.tmp" rm -f "$out" desc=$(printVersion "$DETECTED" "ISO") local msg="Building $desc image" info "$msg..." && html "$msg..." [ -z "$LABEL" ] && LABEL="Windows" if [ ! -f "$dir/$ETFS" ]; then error "Failed to locate file \"$ETFS\" in ISO image!" && return 1 fi size=$(du -b --max-depth=0 "$dir" | cut -f1) checkFreeSpace "$TMP" "$size" || return 1 /run/progress.sh "$out" "$size" "$msg ([P])..." & if [[ "${BOOT_MODE,,}" != "windows_legacy" ]]; then genisoimage -o "$out" -b "$ETFS" -no-emul-boot -c "$cat" -iso-level 4 -J -l -D -N -joliet-long -relaxed-filenames -V "${LABEL::30}" \ -udf -boot-info-table -eltorito-alt-boot -eltorito-boot "$EFISYS" -no-emul-boot -allow-limited-size -quiet "$dir" 2> "$log" || failed="y" else case "${DETECTED,,}" in "win2k"* | "winxp"* | "win2003"* ) genisoimage -o "$out" -b "$ETFS" -no-emul-boot -boot-load-seg 1984 -boot-load-size 4 -c "$cat" -iso-level 2 -J -l -D -N -joliet-long \ -relaxed-filenames -V "${LABEL::30}" -quiet "$dir" 2> "$log" || failed="y" ;; "win9"* ) genisoimage -o "$out" -b "$ETFS" -J -r -V "${LABEL::30}" -quiet "$dir" 2> "$log" || failed="y" ;; * ) genisoimage -o "$out" -b "$ETFS" -no-emul-boot -c "$cat" -iso-level 2 -J -l -D -N -joliet-long -relaxed-filenames -V "${LABEL::30}" \ -udf -allow-limited-size -quiet "$dir" 2> "$log" || failed="y" ;; esac fi fKill "progress.sh" if [ -n "$failed" ]; then [ -s "$log" ] && echo "$(<"$log")" error "Failed to build image!" && return 1 fi local err="" local hide="Warning: creating filesystem that does not conform to ISO-9660." [ -s "$log" ] && err="$(<"$log")" [[ "$err" != "$hide" ]] && echo "$err" mv -f "$out" "$BOOT" || return 1 if ! setOwner "$BOOT"; then warn "Failed to set the owner for \"$BOOT\" !" fi return 0 } bootWindows() { ARGS=$(readState "$STORAGE/windows.args") || return 1 if [ -n "$ARGS" ]; then ARGUMENTS="$ARGS ${ARGUMENTS:-}" fi restoreState "VGA" "$STORAGE/windows.vga" || return 1 restoreState "USB" "$STORAGE/windows.usb" || return 1 restoreState "SOUND" "$STORAGE/windows.sound" || return 1 restoreState "ADAPTER" "$STORAGE/windows.net" || return 1 restoreState "DISK_TYPE" "$STORAGE/windows.type" || return 1 restoreState "BOOT_MODE" "$STORAGE/windows.mode" "Y" || return 1 if [[ "${PLATFORM,,}" == "x64" ]]; then restoreState "MACHINE" "$STORAGE/windows.old" "Y" || return 1 fi return 0 } ###################################### ! parseVersion && exit 58 ! parseLanguage && exit 56 ! detectCustom && exit 59 if ! startInstall; then bootWindows && return 0 exit 68 fi if [ ! -s "$ISO" ] || [ ! -f "$ISO" ]; then if ! downloadImage "$ISO" "$VERSION" "$LANGUAGE"; then rm -f "$ISO" 2> /dev/null || true exit 61 fi fi DIR="$TMP/unpack" if ! extractImage "$ISO" "$DIR" "$VERSION"; then rm -f "$ISO" 2> /dev/null || true exit 62 fi if ! detectImage "$DIR" "$VERSION"; then abortInstall "$DIR" "$ISO" && return 0 exit 60 fi if ! prepareImage "$ISO" "$DIR"; then abortInstall "$DIR" "$ISO" && return 0 exit 66 fi if ! updateImage "$DIR" "$XML" "$LANGUAGE"; then abortInstall "$DIR" "$ISO" && return 0 exit 63 fi if ! removeImage "$ISO"; then exit 64 fi if ! buildImage "$DIR"; then exit 65 fi if ! finishInstall "$BOOT" "N"; then exit 69 fi return 0