<# .SYNOPSIS Activate Windows / Windows Server against a KMS host. .DESCRIPTION Steps: 0. Sync system time (HTTPS Date header, fallback to w32tm), restart sppsvc after a large jump 1. Install the correct GVLK if the OS is not on the VOLUME_KMSCLIENT channel 2. Point the client at the KMS host 3. Send the activation request (with retries) 4. Verify and report the final license status Works on PowerShell 4.0+ (Windows Server 2012 R2 and later). .USAGE Manual (inside the VM, elevated PowerShell) - no parameters needed: irm https://software.vietnix.tech/datastore/proxmox_manager/activate-windows.ps1 | iex Automation (e.g. QEMU guest agent) - options are passed through environment variables, output is a single JSON line on stdout and the process exit code is 0 (activated) or 1: $env:KMS_OUTPUT='json'; $env:KMS_MAX_RETRIES='1'; irm | iex Supported environment variables (all optional): KMS_OUTPUT text | json (default: text) KMS_HOST KMS host name or IP (default: kms.vietnix.vn) KMS_PORT KMS port (default: 1688) KMS_MAX_RETRIES /ato attempts (default: 5 in text mode, 1 in json mode) KMS_RETRY_DELAY seconds between tries (default: 15) KMS_SKIP_TIMESYNC 1 = skip time sync (default: 0) KMS_TIMEZONE Windows time zone id, "none" to skip (default: SE Asia Standard Time) .NOTES GVLKs verified against Microsoft Learn "KMS client activation and product keys": https://learn.microsoft.com/windows-server/get-started/kms-client-activation-keys #> function Invoke-KmsActivation { # ======================= CONFIGURATION ======================= $JsonMode = ($env:KMS_OUTPUT -eq 'json') $KmsHost = if ($env:KMS_HOST) { $env:KMS_HOST } else { "kms.vietnix.vn" } $KmsPort = if ($env:KMS_PORT) { [int]$env:KMS_PORT } else { 1688 } $MaxRetries = if ($env:KMS_MAX_RETRIES) { [int]$env:KMS_MAX_RETRIES } elseif ($JsonMode) { 1 } else { 5 } $RetryDelay = if ($env:KMS_RETRY_DELAY) { [int]$env:KMS_RETRY_DELAY } else { 15 } $SkipTime = ($env:KMS_SKIP_TIMESYNC -eq '1') $TimeZone = if ($env:KMS_TIMEZONE) { $env:KMS_TIMEZONE } else { "SE Asia Standard Time" } $TimeSources = @("https://www.google.com", "https://www.microsoft.com", "https://www.cloudflare.com") # ============================================================= if ($MaxRetries -lt 1) { $MaxRetries = 1 } $ErrorActionPreference = "Continue" $ProgressPreference = "SilentlyContinue" # no progress bars (faster, and keeps stdout clean) $slmgr = "$env:SystemRoot\System32\slmgr.vbs" $WinAppId = "55c92734-d682-4d71-983e-d6ec3f16059f" # ApplicationID of Windows in SoftwareLicensingProduct $kmsTarget = if ($KmsPort -eq 1688) { $KmsHost } else { "$($KmsHost):$KmsPort" } # Ordered list: more specific editions MUST come before generic ones # (e.g. "Pro for Workstations" before "Pro", "Datacenter: Azure Edition" before "Datacenter"). # Patterns are matched against the end of Win32_OperatingSystem.Caption. $GvlkTable = @( # ---- Windows Server 2025 ---- @{ Pattern = "Server 2025 Datacenter: Azure Edition$"; Key = "XGN3F-F394H-FD2MY-PP6FD-8MCRC" } @{ Pattern = "Server 2025 Datacenter$"; Key = "D764K-2NDRG-47T6Q-P8T8W-YP6DF" } @{ Pattern = "Server 2025 Standard$"; Key = "TVRH6-WHNXV-R9WG3-9XRFY-MY832" } # ---- Windows Server 2022 ---- @{ Pattern = "Server 2022 Datacenter: Azure Edition$"; Key = "NTBV8-9K7Q8-V27C6-M2BTV-KHMXV" } @{ Pattern = "Server 2022 Datacenter$"; Key = "WX4NM-KYWYW-QJJR4-XV3QB-6VM33" } @{ Pattern = "Server 2022 Standard$"; Key = "VDYBN-27WPP-V4HQT-9VMD4-VMK7H" } # ---- Windows Server 2019 ---- @{ Pattern = "Server 2019 Datacenter$"; Key = "WMDGN-G9PQG-XVVXX-R3X43-63DFG" } @{ Pattern = "Server 2019 Standard$"; Key = "N69G4-B89J2-4G8F4-WWYCC-J464C" } @{ Pattern = "Server 2019 Essentials$"; Key = "WVDHN-86M7X-466P6-VHXV7-YY726" } # ---- Windows Server 2016 ---- @{ Pattern = "Server 2016 Datacenter$"; Key = "CB7KF-BWN84-R7R2Y-793K2-8XDDG" } @{ Pattern = "Server 2016 Standard$"; Key = "WC2BQ-8NRM3-FDDYY-2BFGV-KHKQY" } @{ Pattern = "Server 2016 Essentials$"; Key = "JCKRF-N37P4-C2D82-9YXRT-4M63B" } # ---- Windows Server 2012 R2 ---- @{ Pattern = "Server 2012 R2 Datacenter$"; Key = "W3GGN-FT8W3-Y4M27-J84CP-Q3VJ9" } @{ Pattern = "Server 2012 R2 Standard$"; Key = "D2N9P-3P6X9-2R39C-7RTCD-MDVJX" } @{ Pattern = "Server 2012 R2 Essentials$"; Key = "KNC87-3J2TX-XB4WP-VCPJV-M4FWM" } # ---- Windows Server 2012 (non-R2) ---- @{ Pattern = "Server 2012 Datacenter$"; Key = "48HP8-DN98B-MYWDG-T2DCC-8W83P" } @{ Pattern = "Server 2012 Standard$"; Key = "XC9B7-NBPP2-83J2H-RHMBY-92BT4" } # ---- Windows 10 / 11 (same GVLK for both) ---- @{ Pattern = "Windows 1[01] Pro for Workstations N$"; Key = "9FNHH-K3HBT-3W4TD-6383H-6XYWF" } @{ Pattern = "Windows 1[01] Pro for Workstations$"; Key = "NRG8B-VKK3Q-CXVCJ-9G2XF-6Q84J" } @{ Pattern = "Windows 1[01] Pro Education N$"; Key = "YVWGF-BXNMC-HTQYQ-CPQ99-66QFC" } @{ Pattern = "Windows 1[01] Pro Education$"; Key = "6TP4R-GNPTD-KYYHQ-7B7DP-J447Y" } @{ Pattern = "Windows 1[01] Pro N$"; Key = "MH37W-N47XK-V7XM9-C7227-GCQG9" } @{ Pattern = "Windows 1[01] Pro$"; Key = "W269N-WFGWX-YVC9B-4J6C9-T83GX" } @{ Pattern = "Windows 1[01] Education N$"; Key = "2WH4N-8QGBV-H22JP-CT43Q-MDWWJ" } @{ Pattern = "Windows 1[01] Education$"; Key = "NW6C2-QMPVW-D7KKK-3GKT6-VCFB2" } @{ Pattern = "Windows 1[01] Enterprise N LTSC"; Key = "92NFX-8DJQP-P6BBQ-THF9C-7CG2H" } @{ Pattern = "Windows 1[01] Enterprise LTSC"; Key = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D" } @{ Pattern = "Windows 1[01] Enterprise N$"; Key = "DPH2V-TTNVB-4X9Q3-TJR4H-KHJW4" } @{ Pattern = "Windows 1[01] Enterprise$"; Key = "NPPR9-FWDCX-D2C8J-H872K-2YT43" } ) $LicenseStatusText = @{ 0 = "Unlicensed"; 1 = "Licensed"; 2 = "Initial grace period"; 3 = "Additional grace period" 4 = "Non-genuine grace period"; 5 = "Notification"; 6 = "Extended grace period" } # Result object (printed as JSON in json mode) $result = [ordered]@{ success = $false licenseStatus = $null reason = $null os = $null edition = $null channel = $null kmsHost = $null cmid = $null expiresMinutes = $null timeSynced = $false timeSkewSeconds = $null clockReverted = $false w32time = "unchanged" gvlkInstalled = $null kmsReachable = $null attempts = 0 lastKmsEvent = $null error = $null log = New-Object System.Collections.ArrayList } # ---------------------------- Output helpers ---------------------------- # Text mode: coloured console output. JSON mode: nothing on the console, messages go to $result.log. function Out-Line([string]$prefix, [string]$text, [string]$color) { [void]$result.log.Add(($prefix + $text).Trim()) if (-not $JsonMode) { Write-Host ($prefix + $text) -ForegroundColor $color } } function Write-Step($t) { Out-Line "`n" $t "Yellow" } function Write-Ok($t) { Out-Line "-> " $t "Green" } function Write-Info($t) { Out-Line "-> " $t "DarkGray" } function Write-Err($t) { Out-Line "-> " $t "Red" } function Write-Banner($t){ if (-not $JsonMode) { Write-Host $t -ForegroundColor Cyan } } # ---------------------------- Helpers ---------------------------- # Run slmgr.vbs through cscript so the output comes back as text (no popup) function Invoke-Slmgr([string]$Arguments) { return (& cscript.exe //nologo $slmgr $Arguments.Split(" ") 2>&1) -join "`n" } # Read the Windows license directly from WMI (language independent) function Get-WindowsLicense { Get-CimInstance -ClassName SoftwareLicensingProduct ` -Filter "ApplicationID='$WinAppId' AND PartialProductKey IS NOT NULL" | Select-Object -First 1 } function Test-KmsPort { try { $client = New-Object Net.Sockets.TcpClient $ok = $client.ConnectAsync($KmsHost, $KmsPort).Wait(5000) $client.Close() return $ok } catch { return $false } } # ---------------------------- Clock helpers ---------------------------- # Get the real UTC time from the Date header of a few HTTPS sites (works when NTP/UDP 123 is blocked). # A stopwatch is returned too, so the time spent between fetching and setting can be added back. function Get-TrueUtc { foreach ($source in $TimeSources) { try { $resp = Invoke-WebRequest -Uri $source -Method Head -UseBasicParsing -TimeoutSec 10 $sw = [Diagnostics.Stopwatch]::StartNew() $dateHeader = [string]($resp.Headers["Date"] | Select-Object -First 1) if (-not $dateHeader) { throw "No Date header returned." } # HTTP Date header is RFC1123 and always in GMT/UTC $utc = [DateTime]::ParseExact($dateHeader, "r", [Globalization.CultureInfo]::InvariantCulture) $utc = [DateTime]::SpecifyKind($utc, [DateTimeKind]::Utc) return @{ Utc = $utc; Source = $source; Stopwatch = $sw } } catch { Write-Info "Time source $source failed: $($_.Exception.Message)" } } return $null } # Compare the local clock with the real time and correct it when the difference is above $Threshold seconds. # Returns @{ Skew = ; Source = ...; Corrected = $true/$false } or $null. function Sync-Clock([int]$Threshold = 2) { $t = Get-TrueUtc if (-not $t) { return $null } $now = $t.Utc + $t.Stopwatch.Elapsed $skew = [Math]::Round(((Get-Date).ToUniversalTime() - $now).TotalSeconds) $corrected = $false if ([Math]::Abs($skew) -gt $Threshold) { Set-Date -Date ($t.Utc + $t.Stopwatch.Elapsed).ToLocalTime() | Out-Null $corrected = $true } return @{ Skew = $skew; Source = $t.Source; Corrected = $corrected } } # sppsvc keeps its own time reference while running. After a large clock jump it may keep using # the old time in KMS requests, so restart it and give it a few seconds to come back. function Restart-Spp { Write-Info "Restarting Software Protection service (sppsvc) to pick up the new time..." Stop-Service sppsvc -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 3 Start-Service sppsvc -ErrorAction SilentlyContinue Start-Sleep -Seconds 5 Write-Info "sppsvc status: $((Get-Service sppsvc).Status)" } # Fill the final status fields of $result from WMI function Update-ResultStatus { $lic = Get-WindowsLicense $svc = Get-CimInstance -ClassName SoftwareLicensingService if ($lic) { $result.licenseStatus = $LicenseStatusText[[int]$lic.LicenseStatus] $result.reason = "0x{0:X8}" -f [uint32]$lic.LicenseStatusReason $result.edition = $lic.Name $result.channel = $lic.Description $result.expiresMinutes = [int]$lic.GracePeriodRemaining $result.success = ($lic.LicenseStatus -eq 1) if ($lic.KeyManagementServiceMachine) { $result.kmsHost = "$($lic.KeyManagementServiceMachine):$($lic.KeyManagementServicePort)" } } if ($svc) { $result.cmid = $svc.ClientMachineID } # Last KMS request logged by Windows (event 12288). The first code in "Info" is the # low-level result (e.g. 0xC0020017 = RPC server unavailable, 0xC002001C = RPC call failed). $lastReq = Get-WinEvent -FilterHashtable @{ LogName = 'Application'; Id = 12288 } -MaxEvents 1 -ErrorAction SilentlyContinue if ($lastReq) { $codeLine = $lastReq.Message -split "`n" | Where-Object { $_ -match "0x" } | Select-Object -First 1 if ($codeLine) { $result.lastKmsEvent = ($codeLine.Trim() -split ",")[0].Trim() } } } # =============================== MAIN =============================== try { Write-Banner "=============================================" Write-Banner " WINDOWS ACTIVATION PROCESS (KMS) " Write-Banner "=============================================" # Must run elevated (SYSTEM via guest agent is fine) $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "This script must be run as Administrator." } $osCaption = (Get-CimInstance -ClassName Win32_OperatingSystem).Caption.Trim() $result.os = $osCaption Write-Info "Detected OS: $osCaption" # ============ [0/4] Time synchronization ============ Write-Step "[0/4] Synchronizing system time..." if ($TimeZone -and $TimeZone -ne 'none') { tzutil /s "$TimeZone" 2>$null | Out-Null Write-Info "Time zone set to: $TimeZone" } # Windows Server 2012 R2 / 2016 do not enable TLS 1.2 by default in PowerShell [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $stoppedW32 = $false if ($SkipTime) { Write-Info "Time sync skipped (KMS_SKIP_TIMESYNC=1)." } else { $first = Sync-Clock 2 if (-not $first) { Write-Err "Could not reach any HTTPS time source. Falling back to Windows Time service (w32tm)..." cmd.exe /c "net start w32time >nul 2>&1 & w32tm /resync >nul 2>&1" | Out-Null Write-Info "w32tm resync requested. Note: this has no effect if NTP (UDP 123) is blocked." } else { $result.timeSynced = $true $result.timeSkewSeconds = $first.Skew if ($first.Corrected) { Write-Ok ("Time synchronized via {0} (clock was off by {1:N0} seconds)." -f $first.Source, $first.Skew) } else { Write-Ok "Clock already accurate (checked against $($first.Source))." } if ([Math]::Abs($first.Skew) -gt 60) { # Large jump: wait a moment and check that the new time actually stuck. # Something on the system (Windows Time service, a VM time provider, a restored # snapshot's state...) may set the clock back right after we change it. Start-Sleep -Seconds 5 $check = Sync-Clock 30 if ($check -and [Math]::Abs($check.Skew) -gt 30) { $result.clockReverted = $true Write-Err ("The clock was set back again ({0:N0} seconds off). Stopping Windows Time service and retrying..." -f $check.Skew) Stop-Service w32time -Force -ErrorAction SilentlyContinue $stoppedW32 = $true Start-Sleep -Seconds 2 [void](Sync-Clock 2) Start-Sleep -Seconds 5 $verify = Sync-Clock 30 if ($verify -and [Math]::Abs($verify.Skew) -gt 30) { Write-Err ("Clock is still being reverted ({0:N0} seconds off). Activation will likely fail with a time error." -f $verify.Skew) } else { Write-Ok "Clock is now stable." } } else { Write-Ok "New time verified (still correct after 5 seconds)." } Restart-Spp } } Write-Info "Current system time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')" } # ============ [1/4] License channel / GVLK ============ Write-Step "[1/4] Checking current license channel..." $license = Get-WindowsLicense if ($license -and $license.Description -match "VOLUME_KMSCLIENT") { Write-Ok "Valid channel detected (VOLUME_KMSCLIENT). No key installation needed." $result.gvlkInstalled = $false } else { $currentChannel = if ($license) { $license.Description } else { "No product key installed" } Write-Info "Current channel: $currentChannel" if ($osCaption -match "Evaluation") { Write-Info "Convert it first, e.g.: DISM /Online /Set-Edition:ServerStandard /ProductKey: /AcceptEula" throw "Evaluation edition detected. GVLKs cannot be installed on Evaluation builds." } $match = $GvlkTable | Where-Object { $osCaption -match $_.Pattern } | Select-Object -First 1 if (-not $match) { throw "No GVLK found for '$osCaption'. Install the correct GVLK manually." } Write-Info "Installing GVLK: $($match.Key) ..." $ipkOutput = Invoke-Slmgr "/ipk $($match.Key)" $license = Get-WindowsLicense if ($license -and $match.Key.EndsWith($license.PartialProductKey)) { Write-Ok "GVLK installed successfully." $result.gvlkInstalled = $true } elseif ($ipkOutput -match "0xC004F069") { throw "0xC004F069: this SKU does not accept the GVLK (Evaluation or unsupported Retail edition)." } else { throw "Failed to install GVLK: $ipkOutput" } } # ============ [2/4] KMS host ============ Write-Step "[2/4] Configuring KMS host ($kmsTarget)..." $skmsOutput = Invoke-Slmgr "/skms $kmsTarget" if ($skmsOutput -match "successfully") { Write-Ok "KMS host configured successfully." } else { Write-Err "KMS host configuration warning: $skmsOutput" } $result.kmsReachable = Test-KmsPort if ($result.kmsReachable) { Write-Ok "KMS host is reachable on TCP $KmsPort." } else { Write-Err "KMS host is NOT reachable on TCP $KmsPort right now (check DNS / firewall / IP conflict). Will still try." } # ============ [3/4] Activation ============ Write-Step "[3/4] Sending activation request (/ato)..." for ($i = 1; $i -le $MaxRetries; $i++) { $result.attempts = $i # Re-check the clock right before every request: KMS rejects requests more than ~4 hours off, # and the clock may have been changed again since step 0. if ($result.timeSynced) { $c = Sync-Clock 30 if ($c -and $c.Corrected) { Write-Err ("Clock drifted again ({0:N0} seconds off) before attempt {1}. Corrected." -f $c.Skew, $i) if ([Math]::Abs($c.Skew) -gt 60) { Restart-Spp } } } $atoOutput = Invoke-Slmgr "/ato" if ((Get-WindowsLicense).LicenseStatus -eq 1) { Write-Ok "Activation succeeded (attempt $i of $MaxRetries)." break } $errLine = ($atoOutput -split "`n" | Where-Object { $_ -match "Error" } | Select-Object -First 1) Write-Info "Attempt $i of $MaxRetries failed: $errLine" if ($i -lt $MaxRetries) { Write-Info "Retrying in $RetryDelay seconds..." Start-Sleep -Seconds $RetryDelay } } } catch { $result.error = $_.Exception.Message Write-Err "[FATAL] $($result.error)" } # ============ Restore Windows Time service if we had to stop it ============ if ($stoppedW32) { try { Write-Step "Restoring Windows Time service..." $inDomain = (Get-CimInstance -ClassName Win32_ComputerSystem).PartOfDomain if (-not $inDomain) { # Workgroup machine: use public NTP servers instead of whatever source was setting the clock back w32tm /config /manualpeerlist:"time.windows.com,0x9 pool.ntp.org,0x9" /syncfromflags:manual /reliable:no 2>&1 | Out-Null Write-Info "Configured NTP peers: time.windows.com, pool.ntp.org" } Start-Service w32time -ErrorAction SilentlyContinue w32tm /config /update 2>&1 | Out-Null Start-Sleep -Seconds 5 # Make sure starting the service did not set the clock back again $after = Sync-Clock 30 if ($after -and [Math]::Abs($after.Skew) -gt 30) { Stop-Service w32time -Force -ErrorAction SilentlyContinue [void](Sync-Clock 2) $result.w32time = "left stopped (it kept setting the clock back)" Write-Err "Windows Time service set the clock back again, so it was left STOPPED. Check its time source: w32tm /query /source" } else { $result.w32time = if ($inDomain) { "restarted" } else { "reconfigured to public NTP and restarted" } Write-Ok "Windows Time service $($result.w32time). Clock is correct." } } catch { Write-Err "Could not restore Windows Time service: $($_.Exception.Message)" } } # ============ [4/4] Result ============ Update-ResultStatus if (-not $JsonMode) { Write-Host "`n=============================================" -ForegroundColor Cyan Write-Host "[4/4] Final license status" -ForegroundColor Yellow Write-Host (" Edition : {0}" -f $result.edition) Write-Host (" Channel : {0}" -f $result.channel) Write-Host (" License status : {0}" -f $result.licenseStatus) Write-Host (" Expires in : {0:N0} minutes (~{1:N0} days)" -f $result.expiresMinutes, ($result.expiresMinutes / 1440)) Write-Host (" KMS host : {0}" -f $(if ($result.kmsHost) { $result.kmsHost } else { "(none)" })) Write-Host (" Client Machine ID: {0}" -f $result.cmid) Write-Host (" System time : {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')) if ($result.clockReverted) { Write-Host (" Windows Time : {0}" -f $result.w32time) -ForegroundColor DarkYellow } if ($result.success) { Write-Host "`n>>> [SUCCESS] Windows is activated. <<<" -ForegroundColor Green } else { Write-Host "`n>>> [FAILED] Windows is NOT activated. <<<" -ForegroundColor Red Write-Host (" Notification reason: {0}" -f $result.reason) -ForegroundColor Red if ($result.lastKmsEvent) { Write-Host (" Last KMS request result: {0}" -f $result.lastKmsEvent) -ForegroundColor Red } Write-Host " 0xC004F074 = KMS host unreachable | 0xC004F038 = KMS count below threshold | 0xC004F06C = time skew" -ForegroundColor DarkGray Write-Host " 0xC0020017 = RPC server unavailable | 0xC002001C = RPC call failed (network / KMS host side)" -ForegroundColor DarkGray } Write-Host "=============================================" -ForegroundColor Cyan } return $result } # =============================== ENTRY POINT =============================== # Wrapped in a function so "irm | iex" does not leave variables behind in the user's session. # "Select -Last 1" guards against any stray pipeline output ending up in the result. $__kmsResult = Invoke-KmsActivation | Select-Object -Last 1 if ($env:KMS_OUTPUT -eq 'json') { # Exactly one JSON line on stdout, then exit with 0 (activated) or 1 (not activated) $__kmsResult | ConvertTo-Json -Compress -Depth 4 | Write-Output $__code = if ($__kmsResult.success) { 0 } else { 1 } Remove-Variable __kmsResult -ErrorAction SilentlyContinue exit $__code } else { # Interactive: do not close the window, just expose the result code $global:LASTEXITCODE = if ($__kmsResult.success) { 0 } else { 1 } Remove-Variable __kmsResult -ErrorAction SilentlyContinue }