diff --git a/docs/environment.md b/docs/environment.md index 4f596b7e..d2a669f4 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -15,6 +15,7 @@ An empty default means the variable is unset and its value is determined automat | `KEYBOARD` | | Keyboard layout. Uses `LANGUAGE` when unset. | | `USERNAME` | `Docker` | Name of the Windows user account. | | `PASSWORD` | `admin` | Password for the Windows account. | +| `DOMAIN` | | Active Directory domain to join during installation. | | `KEY` | | Windows product key used to install and activate Windows. | | `MANUAL` | `N` | Enables manual installation instead of unattended installation. | diff --git a/src/define.sh b/src/define.sh index cb2ca758..f60a4b11 100644 --- a/src/define.sh +++ b/src/define.sh @@ -5,11 +5,13 @@ set -Eeuo pipefail : "${WIDTH:=""}" : "${HEIGHT:=""}" : "${VERIFY:=""}" +: "${DOMAIN:=""}" : "${REGION:=""}" : "${EDITION:=""}" : "${MANUAL:=""}" : "${REMOVE:=""}" : "${VERSION:=""}" +: "${COMMAND:=""}" : "${DETECTED:=""}" : "${KEYBOARD:=""}" : "${LANGUAGE:=""}" @@ -20,7 +22,9 @@ set -Eeuo pipefail KEY=$(strip "$KEY") WIDTH=$(strip "$WIDTH") HEIGHT=$(strip "$HEIGHT") +DOMAIN=$(strip "$DOMAIN") REGION=$(strip "$REGION") +COMMAND=$(strip "$COMMAND") EDITION=$(strip "$EDITION") KEYBOARD=$(strip "$KEYBOARD") LANGUAGE=$(strip "$LANGUAGE") diff --git a/src/install.sh b/src/install.sh index 80af316f..774eb91c 100644 --- a/src/install.sh +++ b/src/install.sh @@ -893,71 +893,327 @@ prepareImage() { 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 tmp result + + domain=$(escapeXML "$2") || return 1 + account=$(escapeXML "$3") || return 1 + auth=$(escapeXML "$4") || return 1 + pass=$(escapeXML "$5") || return 1 + pw="$6" + + 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" \ + 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"] "\n" \ + " \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" + return 1 + fi + + rm -rf "$tmp" + return 0 +} + updateXML() { local asset="$1" local language="$2" - local culture region user admin pass pw keyboard + local app value culture region keyboard edition key + local user raw_user user_xml auth_user safe_user + local admin pass pw domain qualifier - [ -z "$HEIGHT" ] && HEIGHT="720" [ -z "$WIDTH" ] && WIDTH="1280" + [ -z "$HEIGHT" ] && HEIGHT="720" - sed -i "s/>Windows for Docker$APP for $ENGINE1080<\/VerticalResolution>/$HEIGHT<\/VerticalResolution>/g" "$asset" || return 1 - sed -i "s/1920<\/HorizontalResolution>/$WIDTH<\/HorizontalResolution>/g" "$asset" || return 1 + if [[ ! "$WIDTH" =~ ^[0-9]+$ || ! "$HEIGHT" =~ ^[0-9]+$ ]]; then + error "The WIDTH and HEIGHT variables must contain numeric values!" + return 1 + fi + + app=$(escapeXMLSed "$APP for $ENGINE") || return 1 + + sed -i "s|>Windows for Docker<|>$app<|g" "$asset" || return 1 + sed -i "s|1080|$HEIGHT|g" "$asset" || return 1 + sed -i "s|1920|$WIDTH|g" "$asset" || return 1 culture=$(getLanguage "$language" "culture") || return 1 if [ -n "$culture" ] && [[ "${culture,,}" != "en-us" ]]; then - sed -i "s/en-US<\/UILanguage>/$culture<\/UILanguage>/g" "$asset" || return 1 + 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 - sed -i "s/en-US<\/UserLocale>/$region<\/UserLocale>/g" "$asset" || return 1 - sed -i "s/en-US<\/SystemLocale>/$region<\/SystemLocale>/g" "$asset" || return 1 + 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 - sed -i "s/en-US<\/InputLocale>/$keyboard<\/InputLocale>/g" "$asset" || return 1 - sed -i "s/0409:00000409<\/InputLocale>/$keyboard<\/InputLocale>/g" "$asset" || return 1 + 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 - user=$(echo "$USERNAME" | sed 's/[^[:alnum:]@!._-]//g') || return 1 + domain="$DOMAIN" + 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 + + if [[ "$domain" == *"://"* ]]; then + error "The DOMAIN variable must contain a domain name, not a URL!" + return 1 + fi + + auth_user="$USERNAME" + qualifier="" + + if [[ "$auth_user" == *\\* && "$auth_user" == *@* ]]; then + error "The USERNAME variable must use only one domain account format!" + return 1 + fi + + case "$auth_user" in + *\\* ) + qualifier="${auth_user%%\\*}" + user="${auth_user#*\\}" + + if [ -z "$qualifier" ] || [ -z "$user" ] || [[ "$user" == *\\* ]]; then + error "The USERNAME variable does not contain a valid domain account name!" + return 1 + fi + ;; + *@* ) + 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 + ;; + * ) + user="$auth_user" + ;; + esac + + if [[ "${user^^}" == "NONE" ]]; then + error "The USERNAME value \"NONE\" is reserved by Windows!" + return 1 + fi + + if [[ "$user" =~ ^[.[:space:]]+$ ]]; then + error "The USERNAME variable cannot consist only of spaces or periods!" + return 1 + fi + + 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 + + safe_user=$(printf '%s' "$user" | tr -d '"/\\[]:;|=,+*?<>%@') || return 1 + + if [[ "$safe_user" != "$user" ]]; then + error "The domain account name contains characters that are not supported by Windows unattended setup!" + return 1 + fi + + else + + raw_user="$USERNAME" + user=$(printf '%s' "$raw_user" | tr -d '"/\\[]:;|=,+*?<>%@') || return 1 + + if [[ "$user" != "$raw_user" ]]; then + warn "Unsupported characters were removed from the USERNAME value: \"$raw_user\" became \"$user\"." + fi + + if [ -n "$raw_user" ] && [ -z "$user" ]; then + error "The USERNAME variable does not contain a valid local account name!" + return 1 + fi + + if [[ "${user^^}" == "NONE" ]]; then + error "The USERNAME value \"NONE\" is reserved by Windows!" + return 1 + fi + + if [[ "$user" =~ ^[.[:space:]]+$ ]]; then + error "The USERNAME variable cannot consist only of spaces or periods!" + return 1 + fi + + 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 - if [ -n "$user" ]; then - sed -i "s/-name \"Docker\"/-name \"$user\"/g" "$asset" || return 1 - sed -i "s/Docker<\/Name>/$user<\/Name>/g" "$asset" || return 1 - sed -i "s/where name=\"Docker\"/where name=\"$user\"/g" "$asset" || return 1 - sed -i "s/Docker<\/FullName>/$user<\/FullName>/g" "$asset" || return 1 - sed -i "s/Docker<\/Username>/$user<\/Username>/g" "$asset" || return 1 fi - [ -n "$PASSWORD" ] && pass="$PASSWORD" || pass="admin" + 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 "s|password<\/Value>|$admin<\/Value>|g" "$asset" || return 1 - sed -i "s|true<\/PlainText>|<PlainText>false<\/PlainText>|g" "$asset" || return 1 - sed -i -z "s|<Password>...........<Value \/>|<Password>\n <Value>$pw<\/Value>|g" "$asset" || return 1 - sed -i -z "s|<Password>...............<Value \/>|<Password>\n <Value>$pw<\/Value>|g" "$asset" || return 1 - sed -i -z "s|<AdministratorPassword>...........<Value \/>|<AdministratorPassword>\n <Value>$admin<\/Value>|g" "$asset" || return 1 - sed -i -z "s|<AdministratorPassword>...............<Value \/>|<AdministratorPassword>\n <Value>$admin<\/Value>|g" "$asset" || return 1 + sed -i "s|<Value>password</Value>|<Value>$admin</Value>|g" "$asset" || return 1 + sed -i "s|<PlainText>true</PlainText>|<PlainText>false</PlainText>|g" "$asset" || return 1 + sed -i -z "s|<Password>...........<Value />|<Password>\n <Value>$pw</Value>|g" "$asset" || return 1 + sed -i -z "s|<Password>...............<Value />|<Password>\n <Value>$pw</Value>|g" "$asset" || return 1 + sed -i -z "s|<AdministratorPassword>...........<Value />|<AdministratorPassword>\n <Value>$admin</Value>|g" "$asset" || return 1 + sed -i -z "s|<AdministratorPassword>...............<Value />|<AdministratorPassword>\n <Value>$admin</Value>|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"; then + error "Failed to add domain configuration to answer file!" + return 1 + fi + + fi if [ -n "$EDITION" ]; then [[ "${EDITION^^}" == "CORE" ]] && EDITION="STANDARDCORE" - sed -i "s/SERVERSTANDARD<\/Value>/SERVER${EDITION^^}<\/Value>/g" "$asset" || return 1 + 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 '/<ProductKey>/,/<\/ProductKey>/d' "$asset" || return 1 - sed -i "s/<\/UserData>/ <ProductKey>\n <Key>${KEY}<\/Key>\n <WillShowUI>OnError<\/WillShowUI>\n <\/ProductKey>\n <\/UserData>/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