@@ -2247,14 +2247,318 @@ function Invoke-TaskPipeline {
22472247 return $results
22482248}
22492249
2250+ # ============================================================================
2251+ # ============================================================================
2252+ # === RESILIENCE: State Machine, Mutex, Connection Monitor, Boot Recovery ===
2253+ # ============================================================================
2254+
2255+ $script :StateDir = " $env: ProgramData \KeyGate"
2256+ $script :StateFile = " $script :StateDir \activation-state.json"
2257+
2258+ # ── Single Instance Mutex ────────────────────────────────────
2259+ $script :InstanceMutex = $null
2260+ function Acquire-SingleInstanceLock {
2261+ $script :InstanceMutex = [System.Threading.Mutex ]::new($false , " Global\KeyGate_Activation_Lock" )
2262+ if (-not $script :InstanceMutex.WaitOne (0 )) {
2263+ Write-Host " `n ❌ KeyGate is already running on this PC." - ForegroundColor Red
2264+ Write-Host " Only one activation instance can run at a time." - ForegroundColor Yellow
2265+ Write-Host " If the previous instance crashed, delete: $script :StateFile " - ForegroundColor DarkGray
2266+ Start-Sleep - Seconds 3
2267+ exit 1
2268+ }
2269+ }
2270+
2271+ function Release-SingleInstanceLock {
2272+ if ($script :InstanceMutex ) {
2273+ try { $script :InstanceMutex.ReleaseMutex () } catch {}
2274+ $script :InstanceMutex.Dispose ()
2275+ }
2276+ }
2277+
2278+ # ── Persistent State File ────────────────────────────────────
2279+ function Save-ActivationState {
2280+ param ([hashtable ]$State )
2281+ if (-not (Test-Path $script :StateDir )) {
2282+ New-Item - ItemType Directory - Path $script :StateDir - Force | Out-Null
2283+ }
2284+ $State [' updated_at' ] = (Get-Date ).ToString(' yyyy-MM-dd HH:mm:ss' )
2285+ $json = $State | ConvertTo-Json - Depth 5
2286+ # Atomic write: write to temp then move (survives power cut during write)
2287+ $tempFile = " $script :StateFile .tmp"
2288+ [IO.File ]::WriteAllText($tempFile , $json )
2289+ [IO.File ]::Move($tempFile , $script :StateFile , $true )
2290+ }
2291+
2292+ function Load-ActivationState {
2293+ if (Test-Path $script :StateFile ) {
2294+ try {
2295+ $json = [IO.File ]::ReadAllText($script :StateFile )
2296+ return $json | ConvertFrom-Json - AsHashtable
2297+ } catch {
2298+ Write-Host " ⚠️ Corrupt state file, starting fresh" - ForegroundColor Yellow
2299+ Remove-Item $script :StateFile - Force - ErrorAction SilentlyContinue
2300+ }
2301+ }
2302+ return $null
2303+ }
2304+
2305+ function Remove-ActivationState {
2306+ Remove-Item $script :StateFile - Force - ErrorAction SilentlyContinue
2307+ Remove-Item " $script :StateFile .tmp" - Force - ErrorAction SilentlyContinue
2308+ }
2309+
2310+ # ── Connection Monitor ───────────────────────────────────────
2311+ function Wait-ForConnection {
2312+ param (
2313+ [string ]$Context = " server" ,
2314+ [int ]$CheckIntervalSeconds = 2 ,
2315+ [int ]$MaxWaitSeconds = 300 # 5 minutes max
2316+ )
2317+
2318+ $apiHost = ([uri ]$APIBaseURL ).Host
2319+ $elapsed = 0
2320+ $wasDisconnected = $false
2321+
2322+ while ($elapsed -lt $MaxWaitSeconds ) {
2323+ # Quick connectivity check: try API health endpoint first, then ping fallback
2324+ $connected = $false
2325+ try {
2326+ $r = Invoke-WebRequest - Uri " $APIBaseURL /health.php" - Method HEAD - TimeoutSec 3 - UseBasicParsing - ErrorAction Stop
2327+ if ($r.StatusCode -eq 200 ) { $connected = $true }
2328+ } catch {
2329+ # Fallback: ping
2330+ $connected = Test-Connection - ComputerName $apiHost - Count 1 - Quiet - ErrorAction SilentlyContinue
2331+ }
2332+
2333+ if ($connected ) {
2334+ if ($wasDisconnected ) {
2335+ Write-Host " `r ✅ Connection restored! Resuming... " - ForegroundColor Green
2336+ }
2337+ return $true
2338+ }
2339+
2340+ $wasDisconnected = $true
2341+ $remaining = $MaxWaitSeconds - $elapsed
2342+ Write-Host " `r ⏳ Connection lost. Retrying... (${remaining} s remaining) " - ForegroundColor Yellow - NoNewline
2343+ Start-Sleep - Seconds $CheckIntervalSeconds
2344+ $elapsed += $CheckIntervalSeconds
2345+ }
2346+
2347+ Write-Host " `n ❌ Connection timeout after ${MaxWaitSeconds} s" - ForegroundColor Red
2348+ return $false
2349+ }
2350+
2351+ function Invoke-WithRetry {
2352+ param (
2353+ [scriptblock ]$Action ,
2354+ [string ]$Description = " API call" ,
2355+ [int ]$MaxRetries = 3 ,
2356+ [int ]$RetryDelaySeconds = 2
2357+ )
2358+
2359+ for ($i = 1 ; $i -le $MaxRetries ; $i ++ ) {
2360+ try {
2361+ $result = & $Action
2362+ return $result
2363+ } catch {
2364+ $isNetworkError = $_.Exception.Message -match ' Unable to connect|timeout|network|connection'
2365+ if ($isNetworkError -and $i -lt $MaxRetries ) {
2366+ Write-Host " ⚠️ $Description failed (attempt $i /$MaxRetries ): network error" - ForegroundColor Yellow
2367+ $reconnected = Wait-ForConnection - Context $Description - MaxWaitSeconds 60
2368+ if (-not $reconnected ) {
2369+ throw $_
2370+ }
2371+ } elseif ($i -lt $MaxRetries ) {
2372+ Write-Host " ⚠️ $Description failed (attempt $i /$MaxRetries ): $ ( $_.Exception.Message ) " - ForegroundColor Yellow
2373+ Start-Sleep - Seconds $RetryDelaySeconds
2374+ } else {
2375+ throw $_
2376+ }
2377+ }
2378+ }
2379+ }
2380+
2381+ # ── Boot Recovery Scheduled Task ─────────────────────────────
2382+ function Register-BootRecoveryTask {
2383+ $taskName = " KeyGate_ActivationRecovery"
2384+ $stateFile = $script :StateFile
2385+ $apiBase = $APIBaseURL
2386+
2387+ # PowerShell script that runs on boot to complete pending operations
2388+ $recoveryScript = @"
2389+ # KeyGate Boot Recovery — completes pending activation reporting
2390+ `$ stateFile = '$stateFile '
2391+ if (-not (Test-Path `$ stateFile)) { Unregister-ScheduledTask -TaskName '$taskName ' -Confirm:`$ false -EA SilentlyContinue; exit 0 }
2392+ `$ state = Get-Content `$ stateFile -Raw | ConvertFrom-Json
2393+ if (`$ state.phase -eq 'activated' -or `$ state.phase -eq 'key_installed') {
2394+ # Check actual Windows activation status
2395+ try {
2396+ `$ status = (Get-CimInstance -Query "SELECT LicenseStatus FROM SoftwareLicensingProduct WHERE Name LIKE 'Windows%' AND PartialProductKey IS NOT NULL").LicenseStatus
2397+ `$ result = if (`$ status -eq 1) { 'success' } else { 'failed' }
2398+ `$ body = @{ session_token = `$ state.session_token; result = `$ result; attempt_number = [int]`$ state.attempt_number; activation_server = 'oem'; activation_unique_id = `$ state.activation_id; notes = 'Boot recovery: reported after unexpected shutdown' } | ConvertTo-Json
2399+ Invoke-RestMethod -Uri '$apiBase /report-result.php' -Method POST -Body `$ body -ContentType 'application/json' -TimeoutSec 15 -EA Stop
2400+ } catch { Start-Sleep -Seconds 60; exit 1 }
2401+ Remove-Item `$ stateFile -Force -EA SilentlyContinue
2402+ }
2403+ Unregister-ScheduledTask -TaskName '$taskName ' -Confirm:`$ false -EA SilentlyContinue
2404+ "@
2405+
2406+ $encodedCmd = [Convert ]::ToBase64String([Text.Encoding ]::Unicode.GetBytes($recoveryScript ))
2407+
2408+ try {
2409+ # Remove existing task if any
2410+ Unregister-ScheduledTask - TaskName $taskName - Confirm:$false - ErrorAction SilentlyContinue
2411+
2412+ $action = New-ScheduledTaskAction - Execute " powershell.exe" - Argument " -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCmd "
2413+ $trigger = New-ScheduledTaskTrigger - AtStartup
2414+ $settings = New-ScheduledTaskSettingsSet - AllowStartIfOnBatteries - DontStopIfGoingOnBatteries - StartWhenAvailable
2415+ $principal = New-ScheduledTaskPrincipal - UserId " SYSTEM" - RunLevel Highest
2416+
2417+ Register-ScheduledTask - TaskName $taskName - Action $action - Trigger $trigger - Settings $settings - Principal $principal - Force | Out-Null
2418+ Write-Host " 🔒 Boot recovery task registered" - ForegroundColor DarkGray
2419+ } catch {
2420+ Write-Host " ⚠️ Could not register boot recovery task: $_ " - ForegroundColor Yellow
2421+ }
2422+ }
2423+
2424+ function Unregister-BootRecoveryTask {
2425+ try {
2426+ Unregister-ScheduledTask - TaskName " KeyGate_ActivationRecovery" - Confirm:$false - ErrorAction SilentlyContinue
2427+ } catch {}
2428+ }
2429+
2430+ # ── Resume from Previous State ───────────────────────────────
2431+ function Resume-FromState {
2432+ param ([hashtable ]$State )
2433+
2434+ $phase = $State [' phase' ]
2435+ Write-Host " `n 🔄 Resuming from previous session (phase: $phase )" - ForegroundColor Cyan
2436+ Write-Host " Previous run: $ ( $State [' updated_at' ]) " - ForegroundColor DarkGray
2437+
2438+ switch ($phase ) {
2439+ ' authenticated' {
2440+ Write-Host " Re-authentication needed (token likely expired)" - ForegroundColor Yellow
2441+ return $null # Force re-auth
2442+ }
2443+ ' hw_submitted' {
2444+ Write-Host " Hardware already submitted. Proceeding to key request." - ForegroundColor Green
2445+ return @ {
2446+ resume_phase = ' get_key'
2447+ session_token = $State [' session_token' ]
2448+ order_number = $State [' order_number' ]
2449+ activation_id = $State [' activation_id' ]
2450+ }
2451+ }
2452+ ' key_installed' {
2453+ Write-Host " Key was installed. Checking activation status..." - ForegroundColor Green
2454+ # Check if Windows is already activated
2455+ try {
2456+ $licStatus = (Get-CimInstance - Query " SELECT LicenseStatus FROM SoftwareLicensingProduct WHERE Name LIKE 'Windows%' AND PartialProductKey IS NOT NULL" ).LicenseStatus
2457+ if ($licStatus -eq 1 ) {
2458+ Write-Host " ✅ Windows is activated! Just need to report to server." - ForegroundColor Green
2459+ return @ {
2460+ resume_phase = ' report_success'
2461+ session_token = $State [' session_token' ]
2462+ order_number = $State [' order_number' ]
2463+ activation_id = $State [' activation_id' ]
2464+ product_key = $State [' product_key' ]
2465+ }
2466+ } else {
2467+ Write-Host " Key installed but not activated. Retrying activation..." - ForegroundColor Yellow
2468+ return @ {
2469+ resume_phase = ' activate'
2470+ session_token = $State [' session_token' ]
2471+ order_number = $State [' order_number' ]
2472+ activation_id = $State [' activation_id' ]
2473+ product_key = $State [' product_key' ]
2474+ }
2475+ }
2476+ } catch {
2477+ Write-Host " Cannot check status. Will retry activation." - ForegroundColor Yellow
2478+ return @ {
2479+ resume_phase = ' activate'
2480+ session_token = $State [' session_token' ]
2481+ order_number = $State [' order_number' ]
2482+ activation_id = $State [' activation_id' ]
2483+ product_key = $State [' product_key' ]
2484+ }
2485+ }
2486+ }
2487+ ' activated' {
2488+ Write-Host " Activation succeeded! Reporting to server..." - ForegroundColor Green
2489+ return @ {
2490+ resume_phase = ' report_success'
2491+ session_token = $State [' session_token' ]
2492+ order_number = $State [' order_number' ]
2493+ activation_id = $State [' activation_id' ]
2494+ product_key = $State [' product_key' ]
2495+ }
2496+ }
2497+ default {
2498+ Write-Host " Unknown phase '$phase '. Starting fresh." - ForegroundColor Yellow
2499+ Remove-ActivationState
2500+ return $null
2501+ }
2502+ }
2503+ }
2504+
22502505# ============================================================================
22512506# === MAIN EXECUTION ===
22522507# ============================================================================
22532508
2254- # Generate unique activation ID
2255- $script :ActivationUniqueID = New-ActivationUniqueID
2509+ # Acquire single-instance lock FIRST
2510+ Acquire- SingleInstanceLock
2511+
2512+ # Ensure cleanup on exit (normal or Ctrl+C)
2513+ $null = Register-EngineEvent PowerShell.Exiting - Action {
2514+ Release- SingleInstanceLock
2515+ }
2516+ trap {
2517+ Release- SingleInstanceLock
2518+ break
2519+ }
2520+
2521+ # Check for pending state from a previous interrupted session
2522+ $previousState = Load- ActivationState
2523+ $resumeInfo = $null
2524+ if ($previousState ) {
2525+ $resumeInfo = Resume-FromState - State $previousState
2526+ }
2527+
2528+ # Generate unique activation ID (or reuse from previous state)
2529+ if ($resumeInfo -and $resumeInfo.activation_id ) {
2530+ $script :ActivationUniqueID = $resumeInfo.activation_id
2531+ } else {
2532+ $script :ActivationUniqueID = New-ActivationUniqueID
2533+ }
22562534Write-Host " 📋 Activation ID: $ActivationUniqueID " - ForegroundColor Cyan
22572535
2536+ # Handle resume: report success from previous session
2537+ if ($resumeInfo -and $resumeInfo.resume_phase -eq ' report_success' ) {
2538+ Write-Host " `n 📤 Reporting previous activation result to server..." - ForegroundColor Cyan
2539+ try {
2540+ $reportBody = @ {
2541+ session_token = $resumeInfo.session_token
2542+ result = ' success'
2543+ attempt_number = 1
2544+ activation_server = ' oem'
2545+ activation_unique_id = $resumeInfo.activation_id
2546+ notes = " Recovered after interruption. Key: $ ( $resumeInfo.product_key.Substring (0 , 5 )) ..."
2547+ }
2548+ Invoke-WithRetry - Description " Report result" - Action {
2549+ Invoke-APICall - Endpoint " report-result.php" - Body $reportBody
2550+ }
2551+ Write-Host " ✅ Previous activation reported successfully!" - ForegroundColor Green
2552+ Remove-ActivationState
2553+ Unregister-BootRecoveryTask
2554+ Release- SingleInstanceLock
2555+ Start-Sleep - Seconds 3
2556+ exit 0
2557+ } catch {
2558+ Write-Host " ⚠️ Could not report. Will retry on next run." - ForegroundColor Yellow
2559+ }
2560+ }
2561+
22582562# Check activation status FIRST
22592563$alreadyActivated = $false
22602564try {
@@ -2551,6 +2855,17 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) {
25512855 }
25522856
25532857 Write-Host " ✅ Key retrieved: $ ( $keyResponse.oem_identifier ) " - ForegroundColor Green
2858+
2859+ # Save state: key received (survives power loss)
2860+ Save-ActivationState @ {
2861+ phase = ' key_installed'
2862+ session_token = $keyResponse.session_token
2863+ order_number = $OrderNumber
2864+ activation_id = $script :ActivationUniqueID
2865+ product_key = $keyResponse.product_key
2866+ attempt_number = $keyAttempts
2867+ }
2868+ Register-BootRecoveryTask
25542869 if ($keyResponse.key_status -eq ' retry' -and $keyResponse.fail_counter ) {
25552870 Write-Host " ℹ️ This key has $ ( $keyResponse.fail_counter ) previous failure(s)" - ForegroundColor Yellow
25562871 }
@@ -2562,6 +2877,12 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) {
25622877 - OrderNumber $OrderNumber `
25632878 - ActivationUniqueID $script :ActivationUniqueID
25642879
2880+ if ($activationComplete ) {
2881+ # Activation succeeded — clean up state and boot recovery
2882+ Remove-ActivationState
2883+ Unregister-BootRecoveryTask
2884+ }
2885+
25652886 if (-not $activationComplete ) {
25662887 # Clean up failed key before requesting a new one
25672888 Write-Host " `n 🔑 Cleaning up failed key..." - ForegroundColor Yellow
@@ -2572,6 +2893,12 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) {
25722893 }
25732894}
25742895
2896+ if ($activationComplete ) {
2897+ Remove-ActivationState
2898+ Unregister-BootRecoveryTask
2899+ Release- SingleInstanceLock
2900+ }
2901+
25752902if (-not $activationComplete ) {
25762903 if ($KeyExhaustionAction -eq ' retry_loop' ) {
25772904 Write-Host " `n ⚠️ All $MaxKeysToTry key(s) exhausted. Waiting $RetryCooldownSeconds seconds before retrying..." - ForegroundColor Yellow
0 commit comments