Formatting it from a specialize RunSynchronousCommand is not enough on its own. FolderLocations is applied by Shell-Setup while the disk is prepared by Deployment, and component order within a pass is not guaranteed -- so the relocation can be evaluated before the volume it names exists, which fails silently and leaves profiles on C:. That is what a correctly formatted data disk carrying nothing but NTFS metadata was telling us. Audit Mode is a fully booted OS with the disk already attached, so doing it before sysprep makes the volume unconditionally present by the time any pass looks for it. The specialize copy stays, now purely to re-assert the drive letter after generalize clears MountedDevices. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
361 lines
19 KiB
Nix
361 lines
19 KiB
Nix
# Generalize image via sysprep + OOBE in two phases.
|
|
# Phase 1 (sysprep): runs sysprep /generalize /oobe /shutdown in Audit Mode
|
|
# Phase 2 (oobe): boots through OOBE, creates user, activates Windows, shuts down
|
|
# Between phases, NTUSER.DAT can be modified offline.
|
|
# Usage: (templates.generalize { username = "User"; password = ""; })
|
|
{ pkgs, lib, makeFilesISO, ... }:
|
|
let
|
|
masScript = pkgs.fetchurl {
|
|
url = "https://raw.githubusercontent.com/massgravel/Microsoft-Activation-Scripts/166814e52d10204aaa5c3c7db03a3dae9d866509/MAS/All-In-One-Version-KL/MAS_AIO.cmd";
|
|
hash = "sha256-2UsavLok0mxfvhFKFbU6VYaE10oazP95u7JAe+cQKok=";
|
|
};
|
|
in
|
|
{
|
|
username ? "User",
|
|
password ? "",
|
|
autoLogon ? true,
|
|
hostname ? "WIN-VM",
|
|
locale ? "en-US",
|
|
timezone ? "UTC",
|
|
# Desktop background solid color as hex string (e.g. "8e8cd8")
|
|
bgColor ? null,
|
|
# Enable Remote Desktop for the created user (re-applied after sysprep)
|
|
enableRDP ? false,
|
|
# NIC model for the build VM (e.g. "e1000" for images without VirtIO drivers)
|
|
nicModel ? null,
|
|
# Static IPv4 for the guest's single NIC, applied from inside Windows:
|
|
# { address = "10.10.10.26"; prefixLength = 24; gateway = "10.10.10.1";
|
|
# dns = [ "10.10.10.1" ]; }
|
|
staticIP ? null,
|
|
# Relocate user profiles, e.g. "D:\\Users". Needs the volume to exist by the
|
|
# time specialize runs, which is what dataDisk arranges.
|
|
profilesDirectory ? null,
|
|
# Partition the non-OS disk and relocate user profiles onto it, e.g.
|
|
# { driveLetter = "D"; label = "data"; size = "100G"; }. The disk is attached
|
|
# during the build (see extraDisk in the returned set), so this is done and
|
|
# verified before the image ever reaches a host.
|
|
dataDisk ? null,
|
|
# Unified Write Filter: protect a volume by redirecting its writes to a
|
|
# disk-backed overlay held on another one, e.g.
|
|
# { protectedVolume = "C:"; swapfileVolume = "D:"; overlaySizeMB = 8192; }
|
|
writeFilter ? null,
|
|
# delayOobeRun = true: sysprep only, OOBE + activation on real hardware
|
|
# delayOobeRun = false: sysprep + OOBE + activation in build VM
|
|
delayOobeRun ? false,
|
|
}: let
|
|
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
|
|
hexToRgbStr = hex: let
|
|
hexChars = lib.stringToCharacters hex;
|
|
hexToDec = h: let
|
|
c = lib.toLower h;
|
|
m = { "0"=0; "1"=1; "2"=2; "3"=3; "4"=4; "5"=5; "6"=6; "7"=7; "8"=8; "9"=9; "a"=10; "b"=11; "c"=12; "d"=13; "e"=14; "f"=15; };
|
|
in m.${c};
|
|
r = hexToDec (builtins.elemAt hexChars 0) * 16 + hexToDec (builtins.elemAt hexChars 1);
|
|
g = hexToDec (builtins.elemAt hexChars 2) * 16 + hexToDec (builtins.elemAt hexChars 3);
|
|
b = hexToDec (builtins.elemAt hexChars 4) * 16 + hexToDec (builtins.elemAt hexChars 5);
|
|
in "${toString r} ${toString g} ${toString b}";
|
|
|
|
stripHash = s: lib.removePrefix "#" s;
|
|
bgRgb = if bgColor != null then hexToRgbStr (stripHash bgColor) else null;
|
|
|
|
uwfProtected = if writeFilter != null then (writeFilter.protectedVolume or "C:") else "C:";
|
|
uwfSwapVolume = if writeFilter != null then (writeFilter.swapfileVolume or "D:") else "D:";
|
|
uwfOverlaySizeMB = if writeFilter != null then (writeFilter.overlaySizeMB or 8192) else 8192;
|
|
|
|
# Runs from RunOnce on the target's first boot rather than during the build,
|
|
# for two reasons: enabling the DISM feature needs a reboot before uwfmgr
|
|
# exists at all, and the overlay swapfile has to be created on the real data
|
|
# volume rather than on the build's throwaway copy of it.
|
|
#
|
|
# Order is forced by uwfmgr: create-swapfile is only accepted while the
|
|
# filter is off and the overlay is already in disk mode. The default disk
|
|
# overlay would otherwise sit at C:\uwfswap.sys, on the volume being
|
|
# protected. Enabling the filter itself only takes effect after a restart.
|
|
uwfConfigScript = pkgs.writeText "vmix-uwf-config.cmd" ''
|
|
@echo off
|
|
uwfmgr.exe overlay set-type disk
|
|
uwfmgr.exe overlay set-size ${toString uwfOverlaySizeMB}
|
|
uwfmgr.exe volume create-swapfile ${uwfSwapVolume}
|
|
uwfmgr.exe volume protect ${uwfProtected}
|
|
uwfmgr.exe filter enable
|
|
del /q C:\vmix-uwf-config.cmd 2>nul
|
|
shutdown /r /t 10 /c "vmix: activating the write filter"
|
|
'';
|
|
|
|
staticDnsList = lib.optionalString (staticIP != null)
|
|
(lib.concatMapStringsSep "," (s: "'${s}'") staticIP.dns);
|
|
|
|
dataDriveLetter = if dataDisk != null then (dataDisk.driveLetter or "D") else "D";
|
|
dataLabel = if dataDisk != null then (dataDisk.label or "data") else "data";
|
|
|
|
# ProfilesDirectory is only honoured when the volume it names already exists,
|
|
# and a freshly created zvol arrives RAW. Initializing the disk here, in the
|
|
# same specialize pass, brings it up before oobeSystem creates any profile.
|
|
#
|
|
# Idempotent, because specialize runs again on every sysprep: a RAW disk gets
|
|
# a GPT label, one full-size NTFS partition and the drive letter, while a disk
|
|
# that already holds data keeps it and only has its letter re-asserted. The
|
|
# OS disk is added to QEMU first and so is always disk 0.
|
|
initDataDiskScript = pkgs.writeText "vmix-init-data-disk.cmd" ''
|
|
@echo off
|
|
:: diskpart rather than the Storage cmdlets. New-Partition and
|
|
:: Format-Volume need services that are not up yet this early in
|
|
:: specialize, so they fail where Initialize-Disk succeeds -- which left
|
|
:: the disk carrying a GPT header and nothing else, and ProfilesDirectory
|
|
:: pointing at a volume that never existed.
|
|
if exist ${dataDriveLetter}:\ goto :done
|
|
|
|
:: The volume may already be laid out and merely unlettered, in which case
|
|
:: assigning is enough and cleaning would destroy the profile.
|
|
> C:\Windows\Temp\vmix-dd-assign.txt echo select disk 1
|
|
>> C:\Windows\Temp\vmix-dd-assign.txt echo select partition 1
|
|
>> C:\Windows\Temp\vmix-dd-assign.txt echo assign letter=${dataDriveLetter}
|
|
diskpart /s C:\Windows\Temp\vmix-dd-assign.txt > nul 2>&1
|
|
if exist ${dataDriveLetter}:\ goto :cleanup
|
|
|
|
:: Nothing there to keep, so lay the disk out from scratch.
|
|
> C:\Windows\Temp\vmix-dd-init.txt echo select disk 1
|
|
>> C:\Windows\Temp\vmix-dd-init.txt echo clean
|
|
>> C:\Windows\Temp\vmix-dd-init.txt echo convert gpt
|
|
>> C:\Windows\Temp\vmix-dd-init.txt echo create partition primary
|
|
>> C:\Windows\Temp\vmix-dd-init.txt echo format fs=ntfs quick label="${dataLabel}"
|
|
>> C:\Windows\Temp\vmix-dd-init.txt echo assign letter=${dataDriveLetter}
|
|
diskpart /s C:\Windows\Temp\vmix-dd-init.txt
|
|
|
|
:cleanup
|
|
del /q C:\Windows\Temp\vmix-dd-assign.txt C:\Windows\Temp\vmix-dd-init.txt 2>nul
|
|
:done
|
|
'';
|
|
|
|
folderLocationsXml = lib.optionalString (profilesDirectory != null) ''
|
|
<!-- Profiles live on the data disk, so the OS disk stays disposable
|
|
and rebuilding it does not take the profile along -->
|
|
<FolderLocations>
|
|
<ProfilesDirectory>${profilesDirectory}</ProfilesDirectory>
|
|
</FolderLocations>'';
|
|
|
|
dataDiskXml = lib.optionalString (dataDisk != null) ''
|
|
<!-- Runs during specialize, before the first profile is created -->
|
|
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
|
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
|
<RunSynchronous>
|
|
<RunSynchronousCommand wcm:action="add">
|
|
<Order>1</Order>
|
|
<Path>cmd /c C:\vmix-init-data-disk.cmd</Path>
|
|
<Description>vmix: initialize the data disk</Description>
|
|
</RunSynchronousCommand>
|
|
</RunSynchronous>
|
|
</component>'';
|
|
|
|
# Post-OOBE script: runs as the created user via FirstLogonCommands.
|
|
postOobeScript = pkgs.writeText "post-oobe.cmd" ''
|
|
@echo off
|
|
${lib.optionalString (!autoLogon) ''
|
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /f 2>nul
|
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /f 2>nul
|
|
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /f 2>nul
|
|
''}
|
|
${lib.optionalString (bgColor != null) ''
|
|
:: Set solid background color
|
|
reg add "HKCU\Control Panel\Desktop" /v WallPaper /t REG_SZ /d "" /f
|
|
reg add "HKCU\Control Panel\Colors" /v Background /t REG_SZ /d "${bgRgb}" /f
|
|
reg add "HKCU\Control Panel\Desktop" /v WallpaperStyle /t REG_SZ /d "0" /f
|
|
''}
|
|
|
|
${lib.optionalString (password != "") ''
|
|
:: Set user password (OOBE creates with blank password for reliable AutoLogon)
|
|
net user "${username}" "${password}"
|
|
''}
|
|
|
|
:: Set AutoLogon via registry (OOBE unattend AutoLogon is unreliable)
|
|
${lib.optionalString autoLogon ''
|
|
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /t REG_SZ /d "1" /f
|
|
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /t REG_SZ /d "${username}" /f
|
|
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /t REG_SZ /d "${password}" /f
|
|
''}
|
|
|
|
:: Kill sysprep if it was triggered via CopyProfile'd startup entries
|
|
taskkill /f /im sysprep.exe 2>nul
|
|
:: Clean any leftover RunOnce/Run entries from audit phase
|
|
reg delete "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixAudit" /f 2>nul
|
|
reg delete "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v "vmixAudit" /f 2>nul
|
|
|
|
:: Remove Edge AppxPackage for current user (runs in user context during OOBE)
|
|
:: The app is already removed on one of the templates but a ghost appx entry remains that can only be deleted at the user level
|
|
powershell -Command "Get-AppxPackage *MicrosoftEdge* | Remove-AppxPackage -ErrorAction SilentlyContinue"
|
|
powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue"
|
|
|
|
|
|
:: Re-install product key and licenses to restore activation IDs after sysprep
|
|
cscript //nologo C:\Windows\System32\slmgr.vbs /ipk M7XTQ-FN8P6-TTKYV-9D4CC-J462D
|
|
cscript //nologo C:\Windows\System32\slmgr.vbs /rilc
|
|
:: Restart SPP service and wait for it to settle
|
|
net stop sppsvc /y 2>nul
|
|
net start sppsvc
|
|
ping -n 10 127.0.0.1 >nul
|
|
:: Activate Windows using TSforge
|
|
if exist C:\MAS_AIO.cmd (
|
|
echo. | call C:\MAS_AIO.cmd /Z-Windows
|
|
)
|
|
:: Activate Office using Ohook method (if Office is installed)
|
|
if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" (
|
|
if exist C:\MAS_AIO.cmd (
|
|
echo. | call C:\MAS_AIO.cmd /Ohook
|
|
)
|
|
)
|
|
del /q C:\MAS_AIO.cmd 2>nul
|
|
|
|
${lib.optionalString enableRDP ''
|
|
:: Enable RDP
|
|
powershell -Command "Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections -Value 0"
|
|
powershell -Command "Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name UserAuthentication -Value 1"
|
|
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Lsa" /v LimitBlankPasswordUse /t REG_DWORD /d 0 /f
|
|
:: Create firewall rules for all profiles (New-NetFirewallRule is more reliable than Enable-NetFirewallRule)
|
|
powershell -Command "New-NetFirewallRule -DisplayName 'RDP (TCP)' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3389 -RemoteAddress Any -Profile Any -Enabled True | Out-Null"
|
|
powershell -Command "New-NetFirewallRule -DisplayName 'RDP (UDP)' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 3389 -RemoteAddress Any -Profile Any -Enabled True | Out-Null"
|
|
:: Set all RDP services to auto-start
|
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SessionEnv" /v Start /t REG_DWORD /d 2 /f
|
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\UmRdpService" /v Start /t REG_DWORD /d 2 /f
|
|
reg add "HKLM\SYSTEM\CurrentControlSet\Services\TermService" /v Start /t REG_DWORD /d 2 /f
|
|
''}
|
|
|
|
${lib.optionalString (staticIP != null) ''
|
|
:: This VM's only NIC sits on a macvtap bridged to the host's LAN, so its
|
|
:: address is a LAN address that nothing hands out -- the guest asserts it.
|
|
:: Clearing first makes the command idempotent across re-runs.
|
|
powershell -NoProfile -Command "$a = Get-NetAdapter -Physical | Sort-Object ifIndex | Select-Object -First 1; Remove-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; Remove-NetRoute -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue; New-NetIPAddress -InterfaceIndex $a.ifIndex -IPAddress '${staticIP.address}' -PrefixLength ${toString staticIP.prefixLength} -DefaultGateway '${staticIP.gateway}' | Out-Null; Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ${staticDnsList}"
|
|
''}
|
|
|
|
${lib.optionalString (writeFilter != null) ''
|
|
:: Install the feature now, but defer configuring it: uwfmgr does not exist
|
|
:: until this has been through a reboot, and the swapfile belongs on the
|
|
:: real data volume, so RunOnce picks it up on the target's first boot.
|
|
dism /online /enable-feature /featurename:Client-UnifiedWriteFilter /all /norestart
|
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v "vmixUwf" /t REG_SZ /d "C:\vmix-uwf-config.cmd" /f
|
|
''}
|
|
|
|
:: Clean up
|
|
del /q C:\oobe-unattend.xml 2>nul
|
|
del /q C:\vmix-audit-script.cmd 2>nul
|
|
del /q C:\vmix-audit-wrapper.cmd 2>nul
|
|
|
|
${if delayOobeRun then "" else "shutdown /s /t 5 /c \"vmix generalize complete\""}
|
|
del /q C:\post-oobe.cmd 2>nul
|
|
'';
|
|
|
|
oobeXml = pkgs.writeText "oobe-unattend.xml" ''
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
|
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
|
<!-- Copy Administrator profile to default (preserves Audit Mode customizations) -->
|
|
<settings pass="specialize">
|
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
|
<CopyProfile>true</CopyProfile>
|
|
<Themes>
|
|
<WindowColor>Automatic</WindowColor>
|
|
</Themes>
|
|
${folderLocationsXml}
|
|
</component>
|
|
${dataDiskXml}
|
|
</settings>
|
|
|
|
<settings pass="oobeSystem">
|
|
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
|
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
|
<InputLocale>${locale}</InputLocale>
|
|
<SystemLocale>${locale}</SystemLocale>
|
|
<UILanguage>${locale}</UILanguage>
|
|
<UserLocale>${locale}</UserLocale>
|
|
</component>
|
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
|
<OOBE>
|
|
<HideEULAPage>true</HideEULAPage>
|
|
<HideLocalAccountScreen>true</HideLocalAccountScreen>
|
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
|
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
|
<NetworkLocation>Work</NetworkLocation>
|
|
<SkipMachineOOBE>true</SkipMachineOOBE>
|
|
<SkipUserOOBE>true</SkipUserOOBE>
|
|
<ProtectYourPC>3</ProtectYourPC>
|
|
</OOBE>
|
|
<UserAccounts>
|
|
<LocalAccounts>
|
|
<LocalAccount wcm:action="add">
|
|
<Password>
|
|
<Value></Value>
|
|
<PlainText>true</PlainText>
|
|
</Password>
|
|
<Group>Administrators</Group>
|
|
<Name>${username}</Name>
|
|
</LocalAccount>
|
|
</LocalAccounts>
|
|
</UserAccounts>
|
|
<AutoLogon>
|
|
<Password>
|
|
<Value></Value>
|
|
<PlainText>true</PlainText>
|
|
</Password>
|
|
<Enabled>true</Enabled>
|
|
<LogonCount>999</LogonCount>
|
|
<Username>${username}</Username>
|
|
</AutoLogon>
|
|
<ComputerName>${hostname}</ComputerName>
|
|
<TimeZone>${timezone}</TimeZone>
|
|
<FirstLogonCommands>
|
|
<SynchronousCommand wcm:action="add">
|
|
<Order>1</Order>
|
|
<CommandLine>C:\post-oobe.cmd</CommandLine>
|
|
<RequiresUserInput>false</RequiresUserInput>
|
|
</SynchronousCommand>
|
|
</FirstLogonCommands>
|
|
</component>
|
|
</settings>
|
|
</unattend>
|
|
'';
|
|
in {
|
|
name = if delayOobeRun then "generalize-delay-oobe" else "generalize";
|
|
inherit nicModel;
|
|
# The blank disk is attached for the Audit Mode boot itself, so the disk-init
|
|
# command and the profile relocation both happen under OOBE in the build VM.
|
|
# That is what makes delayOobeRun unnecessary: nothing is left to do on real
|
|
# hardware. The written disk comes back as this derivation's `data` output.
|
|
extraDisk = if dataDisk != null then { size = dataDisk.size or "100G"; } else null;
|
|
uploads = [
|
|
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
|
|
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
|
|
{ source = masScript; dest = "/MAS_AIO.cmd"; }
|
|
] ++ lib.optional (dataDisk != null) { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
|
|
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; };
|
|
# delayOobeRun: sysprep + shutdown — OOBE runs on real hardware
|
|
# generalize: sysprep + reboot into OOBE in the same QEMU session
|
|
auditScript = ''
|
|
@echo off
|
|
:: Remove cached Autounattend from initial install (contains Audit Mode reseal)
|
|
del /q C:\Windows\Panther\unattend.xml 2>nul
|
|
del /q C:\Windows\Panther\Unattend\unattend.xml 2>nul
|
|
del /q C:\Windows\System32\Sysprep\Panther\unattend.xml 2>nul
|
|
${lib.optionalString (dataDisk != null) ''
|
|
:: Lay the data disk out here, in Audit Mode, rather than leaving it to the
|
|
:: specialize pass alone. Component order within a pass is not guaranteed,
|
|
:: and FolderLocations is applied by Shell-Setup while the disk is prepared
|
|
:: by Deployment -- so relocation can be evaluated before the volume it
|
|
:: names exists, which silently leaves profiles on C:. Audit Mode is a
|
|
:: fully booted OS with the disk already attached, so this always works.
|
|
:: The specialize copy stays as a letter re-assertion after generalize
|
|
:: clears MountedDevices.
|
|
call C:\vmix-init-data-disk.cmd
|
|
''}
|
|
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe ${if delayOobeRun then "/shutdown" else "/reboot"} /quiet /unattend:C:\oobe-unattend.xml
|
|
'';
|
|
}
|
|
|
|
# :: Enable RDP (sysprep resets offline registry changes)
|
|
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
|
|
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v UserAuthentication /t REG_DWORD /d 0 /f
|
|
# netsh advfirewall firewall add rule name="RDP" dir=in protocol=tcp localport=3389 action=allow
|
|
# :: Start and enable the RDP service
|
|
# sc config TermService start= auto
|
|
# net start TermService
|
|
|