Skip to content

Commit de1ba62

Browse files
ChesnoTechclaude
andauthored
Add activation resilience: state machine, mutex, connection monitor, boot recovery (#6)
Handles connection loss, forced shutdowns, and duplicate instances: - Single-instance mutex: prevents two KeyGate instances running simultaneously - Persistent state file (C:\ProgramData\KeyGate\activation-state.json): writes state BEFORE each step, survives power cuts - Connection monitor: 2-second polling with auto-resume on reconnect - Invoke-WithRetry: wraps API calls with automatic retry + connection wait - Boot recovery scheduled task: on next boot, checks for pending state and reports activation result to server - Resume-FromState: on next launch, detects interrupted session and resumes from the correct phase (hw_submitted, key_installed, activated) Tested on Windows 11: - Mutex: correctly blocks second instance - State file: atomic write (tmp + move) survives interruption - Connection check: ping + HTTP health fallback works - Boot recovery: requires admin (CMD launcher always runs as admin) Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 271c9c9 commit de1ba62

1 file changed

Lines changed: 329 additions & 2 deletions

File tree

FINAL_PRODUCTION_SYSTEM/activation/main_v3.PS1

Lines changed: 329 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
22562534
Write-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
22602564
try {
@@ -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+
25752902
if (-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

Comments
 (0)