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|false|g" "$asset" || 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 -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|[^<]*|false|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:]]*])/,/^[[: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