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>|false<\/PlainText>|g" "$asset" || return 1
- sed -i -z "s|...........|\n $pw<\/Value>|g" "$asset" || return 1
- sed -i -z "s|...............|\n $pw<\/Value>|g" "$asset" || return 1
- sed -i -z "s|...........|\n $admin<\/Value>|g" "$asset" || return 1
- sed -i -z "s|...............|\n $admin<\/Value>|g" "$asset" || return 1
+ sed -i "s|password|$admin|g" "$asset" || return 1
+ sed -i "s|true|false|g" "$asset" || return 1
+ sed -i -z "s|...........|\n $pw|g" "$asset" || return 1
+ sed -i -z "s|...............|\n $pw|g" "$asset" || return 1
+ sed -i -z "s|...........|\n $admin|g" "$asset" || return 1
+ sed -i -z "s|...............|\n $admin|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|SERVER$edition|g" "$asset" || return 1
fi
if [ -n "$KEY" ]; then
+ key=$(escapeXMLSed "$KEY") || return 1
sed -i '//,/<\/ProductKey>/d' "$asset" || return 1
- sed -i "s/<\/UserData>/ \n ${KEY}<\/Key>\n OnError<\/WillShowUI>\n <\/ProductKey>\n <\/UserData>/g" "$asset" || return 1
+ sed -i "s|| \n $key\n OnError\n \n |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