diff --git a/docs/environment.md b/docs/environment.md index 500b109d..f7b5fead 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -11,12 +11,15 @@ An empty default means the variable is unset and its value is determined automat | `VERSION` | `11` | Windows version to install, such as `10` or `11`. | | `EDITION` | | Windows edition to install, such as `core` for Windows Server Core. | | `LANGUAGE` | `en-US` | Windows display language, such as `English`, `en-US`, or `en`. | +| `KEY` | | Windows product key used to install and activate Windows. | | `REGION` | | Windows regional format. Uses `LANGUAGE` when unset. | | `KEYBOARD` | | Keyboard layout. Uses `LANGUAGE` when unset. | | `USERNAME` | `Docker` | Name of the Windows user account. | | `PASSWORD` | `admin` | Password for the Windows account. | +| `AUTOLOGIN` | `Y` | Automatically signs in to Windows after startup. | | `DOMAIN` | | Active Directory domain to join during installation. | -| `KEY` | | Windows product key used to install and activate Windows. | +| `DOMAIN_OU` | | Distinguished name of the organizational unit. | +| `WORKGROUP` | | Name of the Windows workgroup to join. | ## 🧠 CPU and Memory @@ -162,7 +165,7 @@ Also see [Dynamic memory allocation](https://github.com/qemus/qemu/blob/master/d | Variable | Default | Description | |---|---|---| | `SHUTDOWN` | `Y` | Enables graceful ACPI shutdown. | -| `TIMEOUT` | `115` | Maximum time, in seconds, to wait before forcing Windows to stop. | +| `TIMEOUT` | `105` | Maximum time, in seconds, to wait before forcing Windows to stop. | ## 🐞 Debugging diff --git a/readme.md b/readme.md index ba421fff..32d62424 100644 --- a/readme.md +++ b/readme.md @@ -171,7 +171,7 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/windows/refs/heads/mas - ./example:/shared ``` - Replace the example path `./example` with your desired shared folder, which then will become visible as `Shared`. + Replace the example path `./example` with your desired shared folder, which then will become visible as `Shared` on the desktop and as drive `Z:`. ### How do I change the amount of CPU or RAM? @@ -225,9 +225,10 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/windows/refs/heads/mas ```yaml environment: DOMAIN: "example.com" + DOMAIN_OU: "OU=Virtual Machines,OU=Servers,DC=example,DC=com" ``` - Use the domain name, such as `example.com`, rather than a URL. The supplied account is added to the local Administrators group and automatically signed in after installation. + Use the domain name, such as `example.com`, rather than a URL. The supplied account is added to the local Administrators group and automatically signed in after installation. `DOMAIN_OU` is optional and specifies where the computer account should be created. Windows must be able to resolve and reach the domain controller through the domain's DNS server. diff --git a/src/define.sh b/src/define.sh index a945f2cd..5b7699d0 100644 --- a/src/define.sh +++ b/src/define.sh @@ -18,6 +18,9 @@ set -Eeuo pipefail : "${LANGUAGE:=""}" : "${USERNAME:=""}" : "${PASSWORD:=""}" +: "${DOMAIN_OU:=""}" +: "${WORKGROUP:=""}" +: "${AUTOLOGIN:=""}" # Sanitize variables KEY=$(strip "$KEY") @@ -31,6 +34,9 @@ EDITION=$(strip "$EDITION") KEYBOARD=$(strip "$KEYBOARD") LANGUAGE=$(strip "$LANGUAGE") USERNAME=$(strip "$USERNAME") +DOMAIN_OU=$(strip "$DOMAIN_OU") +WORKGROUP=$(strip "$WORKGROUP") +AUTOLOGIN=$(strip "$AUTOLOGIN") MIRRORS=3 @@ -1365,6 +1371,49 @@ validateComputerName() { return 0 } +validateWorkgroup() { + + local value="$1" + local safe="" + + [ -z "$value" ] && return 0 + + if [ "${#value}" -gt 15 ]; then + error "The WORKGROUP variable cannot contain more than 15 characters!" + return 1 + fi + + safe=$(printf '%s' "$value" | tr -d '"/\\[]:;|=,+*?<>') || return 1 + + if [[ "$safe" != "$value" ]]; then + error "The WORKGROUP variable contains characters that are not valid in a NetBIOS name!" + return 1 + fi + + if [[ "$value" =~ ^[.[:space:]]+$ ]]; then + error "The WORKGROUP variable cannot consist only of spaces or periods!" + return 1 + fi + + return 0 +} + +validateMembership() { + + if [ -n "$DOMAIN" ] && [ -n "$WORKGROUP" ]; then + error "The DOMAIN and WORKGROUP variables cannot be used together!" + return 1 + fi + + if [ -n "$DOMAIN_OU" ] && [ -z "$DOMAIN" ]; then + error "The DOMAIN_OU variable requires DOMAIN to be specified!" + return 1 + fi + + validateWorkgroup "$WORKGROUP" || return 1 + return 0 +} + validateLegacyText() { local name="$1" @@ -1426,6 +1475,11 @@ validateLegacyUsername() { return 1 fi + if [[ "${value^^}" == "GUEST" ]]; then + error "The USERNAME value \"$value\" is reserved for a built-in Windows account$suffix!" + return 1 + fi + return 0 } @@ -1704,6 +1758,7 @@ prepareInstall() { validateResolution "WIDTH" "$WIDTH" 320 || return 1 validateResolution "HEIGHT" "$HEIGHT" 200 || return 1 + validateMembership || return 1 validateComputerName "$HOST" || return 1 validateLegacyText "APP" "$APP" "$desc" || return 1 validateLegacyText "ENGINE" "$ENGINE" "$desc" || return 1 @@ -1713,7 +1768,8 @@ prepareInstall() { local username="${USERNAME:-Docker}" local password="${PASSWORD:-admin}" - local sifHost sifUsername sifPassword sifOrganization + local workgroup="${WORKGROUP:-WORKGROUP}" + local sifHost sifUsername sifPassword sifOrganization sifWorkgroup local regUsername regPassword validateLegacyUsername "$username" "$desc" || return 1 @@ -1723,6 +1779,7 @@ prepareInstall() { sifUsername=$(escapeSIFValue "$username") || return 1 sifPassword=$(escapeSIFValue "$password") || return 1 sifOrganization=$(escapeSIFValue "$APP for $ENGINE") || return 1 + sifWorkgroup=$(escapeSIFValue "$workgroup") || return 1 regUsername=$(escapeRegistryValue "$username") || return 1 regPassword=$(escapeRegistryValue "$password") || return 1 @@ -1756,8 +1813,12 @@ prepareInstall() { echo " OemSkipWelcome=1" echo " AdminPassword=\"$sifPassword\"" echo " TimeZone=0" - echo " AutoLogon=Yes" - echo " AutoLogonCount=65432" + if disabled "$AUTOLOGIN"; then + echo " AutoLogon=No" + else + echo " AutoLogon=Yes" + echo " AutoLogonCount=65432" + fi echo "" echo "[UserData]" echo " FullName=\"$sifUsername\"" @@ -1766,7 +1827,7 @@ prepareInstall() { echo " $product" echo "" echo "[Identification]" - echo " JoinWorkgroup = WORKGROUP" + echo " JoinWorkgroup = \"$sifWorkgroup\"" echo "" echo "[Display]" echo " BitsPerPel=32" @@ -1827,10 +1888,14 @@ prepareInstall() { echo "\"Desktopchanged\"=\"1\"" echo "" echo "[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon]" - echo "\"AutoAdminLogon\"=\"1\"" - echo "\"DefaultUserName\"=\"$regUsername\"" - echo "\"DefaultPassword\"=\"$regPassword\"" - echo "\"DefaultDomainName\"=\"Dockur\"" + if disabled "$AUTOLOGIN"; then + echo "\"AutoAdminLogon\"=\"0\"" + else + echo "\"AutoAdminLogon\"=\"1\"" + echo "\"DefaultUserName\"=\"$regUsername\"" + echo "\"DefaultPassword\"=\"$regPassword\"" + echo "\"DefaultDomainName\"=\"Dockur\"" + fi echo "" echo "[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Video\{23A77BF7-ED96-40EC-AF06-9B1F4867732A}\0000]" echo "\"DefaultSettings.BitsPerPel\"=dword:00000020" @@ -1845,6 +1910,7 @@ prepareInstall() { echo "[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunOnce]" echo "\"ScreenSaver\"=\"reg add \\\"HKCU\\\\Control Panel\\\\Desktop\\\" /f /v \\\"SCRNSAVE.EXE\\\" /t REG_SZ /d \\\"off\\\"\"" echo "\"ScreenSaverOff\"=\"reg add \\\"HKCU\\\\Control Panel\\\\Desktop\\\" /f /v \\\"ScreenSaveActive\\\" /t REG_SZ /d \\\"0\\\"\"" + echo "\"SharedDrive\"=\"cmd /C net use Z: \\\\\\\\host.lan\\\\Data /persistent:yes\"" echo "$oem" echo "" } | unix2dos > "$dir/\$OEM\$/install.reg" || return 1 diff --git a/src/install.sh b/src/install.sh index c6220a62..3197d135 100644 --- a/src/install.sh +++ b/src/install.sh @@ -771,6 +771,8 @@ 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 @@ -781,6 +783,10 @@ setXML() { [ ! -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 } @@ -924,18 +930,19 @@ updateDomain() { local asset="$1" local domain account auth pass - local pw cred_domain tmp result + 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="" ;; + *@* ) cred_domain="" ;; esac grep -Eq 'Microsoft-Windows-UnattendedJoin|])' "$asset" && return 1 @@ -943,8 +950,9 @@ updateDomain() { 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" \ + 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" } @@ -996,8 +1004,13 @@ updateDomain() { print " " ENVIRON["AUTH_XML"] "\n" \ " " ENVIRON["PASS_XML"] "\n" \ " \n" \ - " " ENVIRON["DOMAIN_XML"] "\n" \ - " \n" \ + " " ENVIRON["DOMAIN_XML"] "" + + if (ENVIRON["OU_XML"] != "") { + print " " ENVIRON["OU_XML"] "" + } + + print " \n" \ " " join_added = 1 @@ -1057,7 +1070,7 @@ validateLocalUsername() { "NONE" ) error "The USERNAME value \"NONE\" is reserved by Windows!" return 1 ;; - "ADMINISTRATOR" | "GUEST" ) + "ADMINISTRATOR" | "GUEST" | "DEFAULTACCOUNT" | "WDAGUTILITYACCOUNT" | "WSIACCOUNT" ) error "The USERNAME value \"$value\" is reserved for a built-in Windows account!" return 1 ;; esac @@ -1075,27 +1088,20 @@ validateDomainName() { return 1 fi - if [ "${#value}" -gt 255 ]; then - error "The $name variable cannot contain more than 255 characters!" - return 1 - fi - if [[ "$value" == *"://"* ]]; then error "The $name variable must contain a domain name, not a URL!" return 1 fi - if [[ "$value" =~ [[:cntrl:]] ]] || [[ "$value" =~ [[:space:]] ]]; then - error "The $name variable cannot contain whitespace or control characters!" + 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 - case "$value" in - *'/'* | *\\* | *'@'* | *':'* ) - error "The $name variable does not contain a valid domain name!" - return 1 ;; - esac - return 0 } @@ -1142,19 +1148,62 @@ validateDomainUsername() { 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 - local admin pass pw domain qualifier host + 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 @@ -1164,8 +1213,8 @@ updateXML() { sed -i "s|>Windows for Docker<|>$app<|g" "$asset" || return 1 sed -i -E "s|[^<]*|$host|g" "$asset" || return 1 - sed -i "s|1080|$HEIGHT|g" "$asset" || return 1 - sed -i "s|1920|$WIDTH|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 @@ -1193,6 +1242,7 @@ updateXML() { fi domain="$DOMAIN" + workgroup="$WORKGROUP" case "${DETECTED,,}" in "win10x64"* | "win11x64"* ) ;; * ) domain="" ;; @@ -1215,21 +1265,12 @@ updateXML() { auth_user="$USERNAME" qualifier="" - if [[ "$auth_user" == *\\* && "$auth_user" == *@* ]]; then - error "The USERNAME variable must use only one domain account format!" + if [[ "$auth_user" == *\\* ]]; then + error "The USERNAME variable must use either \"user\" or \"user@domain\" 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#*@}" @@ -1238,6 +1279,13 @@ updateXML() { 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" @@ -1246,10 +1294,6 @@ updateXML() { validateDomainUsername "$user" || return 1 - if [ -n "$qualifier" ]; then - validateDomainName "$qualifier" "USERNAME" || return 1 - fi - if [[ "${user,,}" == "docker" ]]; then error "The USERNAME variable must be changed from its default value when joining a domain!" return 1 @@ -1286,20 +1330,31 @@ updateXML() { 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|$admin|g" "$asset" || return 1 - sed -i "s|true</PlainText>|<PlainText>false</PlainText>|g" "$asset" || return 1 - sed -i -z -E "s|<Password>([[:space:]]*)<Value[[:space:]]*/>|<Password>\1<Value>$pw</Value>|g" "$asset" || return 1 - sed -i -z -E "s|<AdministratorPassword>([[:space:]]*)<Value[[:space:]]*/>|<AdministratorPassword>\1<Value>$admin</Value>|g" "$asset" || return 1 + sed -i -z -E "s#(<Password>[[:space:]]*<Value)([[:space:]]*/>|>[^<]*</Value>)#\1>$pw</Value>#g" "$asset" || return 1 + sed -i -z -E "s#(<AdministratorPassword>[[:space:]]*<Value)([[:space:]]*/>|>[^<]*</Value>)#\1>$admin</Value>#g" "$asset" || return 1 + sed -i -E "s|<PlainText>[^<]*</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"; then + 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 @@ -1536,9 +1591,20 @@ updateImage() { fi - if ! updateXML "$answer" "$language"; then - error "Failed to update answer file: $answer" - return 1 + 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