From 271c9c960908fe3708872b5ec583a374e2eecbb1 Mon Sep 17 00:00:00 2001 From: Ayoub Mohamed Samir <7agtyadmin@gmail.com> Date: Tue, 24 Mar 2026 11:41:41 +0200 Subject: [PATCH 1/2] Set up Git Flow: branch protection, PR template, contributing guide (#4) - Protected main: require PR + CI (PHP Lint, Frontend Build, Docker) - Protected develop: require PR + CI (PHP Lint, Frontend Build) - Updated PR template with Git Flow branch types and testing checklist - Rewrote CONTRIBUTING.md with full Git Flow workflow, branch naming, and development setup instructions - Repo is now public (BSL licensed) Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .github/pull_request_template.md | 42 ++++++++----- CONTRIBUTING.md | 102 +++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 33 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c7451d1..85875c7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,38 @@ ## Summary - -## Changes +## Branch Type +- [ ] `feature/` — New feature (→ develop) +- [ ] `fix/` — Bug fix (→ develop) +- [ ] `hotfix/` — Urgent production fix (→ main + develop) +- [ ] `release/` — Release prep (→ main + develop) +- [ ] `docs/` — Documentation only +- [ ] `ci/` — CI/CD changes +## Changes +- - -## Component(s) affected - -- [ ] Activation Client (PowerShell) +## Components Affected +- [ ] Activation Client (PS1 / CMD) - [ ] Admin Panel (React frontend) -- [ ] API Backend (PHP) -- [ ] Docker / Deployment +- [ ] API Backend (PHP controllers) - [ ] Database / Migrations +- [ ] License Server (Cloudflare Worker) +- [ ] Docker / Deployment - [ ] CI / GitHub Actions -- [ ] Documentation - -## Testing -- [ ] `cd frontend && npm test` passes -- [ ] `php -l` on changed PHP files -- [ ] Docker stack starts cleanly -- [ ] Tested manually in browser / on workstation +## Testing Checklist +- [ ] `cd frontend && npm test` — 14 tests pass +- [ ] `npm run build` — no TypeScript errors +- [ ] PHP lint: `docker compose exec web php -l ` +- [ ] Docker stack starts cleanly (`docker compose up -d`) +- [ ] New i18n keys added to `en.json` + `ru.json` +- [ ] New admin actions added to `api-contracts.test.ts` +- [ ] Tested in browser / on workstation -## Screenshots (if UI changes) +## Screenshots + - +## Related Issues + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fd787e..f104458 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,34 +1,95 @@ -# Contributing +# Contributing to KeyGate + +## Git Flow Branching Strategy + +``` +main (production) ←── tagged releases (v2.1.0, v2.2.0) + ↑ PR (require CI green) Protected: no direct push +develop (integration) ←── all features merge here first + ↑ PR (require CI green) Protected: no direct push +feature/my-feature ←── one branch per feature/fix +hotfix/urgent-fix ←── branch from main → merge to main + develop +release/v2.2.0 ←── branch from develop → merge to main + develop +``` + +### Branch Types + +| Prefix | Base Branch | Merges Into | Purpose | +|--------|------------|-------------|---------| +| `feature/` | develop | develop | New features | +| `fix/` | develop | develop | Bug fixes | +| `hotfix/` | main | main + develop | Urgent production fixes | +| `release/` | develop | main + develop | Release preparation | +| `docs/` | develop | develop | Documentation only | +| `ci/` | develop | develop | CI/CD changes | + +### Workflow + +```bash +# 1. Create a feature branch +git checkout develop +git pull origin develop +git checkout -b feature/my-new-feature + +# 2. Work on your branch +git add -A && git commit -m "Add new widget controller" + +# 3. Push and create PR +git push origin feature/my-new-feature +gh pr create --base develop --title "Add widget feature" + +# 4. CI must pass before merge (PHP Lint + Frontend Build & Test) + +# 5. Release flow (when ready to ship): +git checkout develop +git checkout -b release/v2.2.0 +# Update VERSION.php, then PR to main +gh pr create --base main --title "Release v2.2.0" +# After merge, tag: git tag v2.2.0 && git push origin v2.2.0 +``` + +### Branch Naming Examples + +``` +feature/add-technician-export +feature/dpk-xml-parser +fix/login-session-timeout +fix/key-pool-alert-email +hotfix/activation-crash-win11 +release/v2.2.0 +``` ## Development Setup ```bash -# Clone and start -git clone https://github.com/ChesnoTech/OEM_Activation_System.git -cd OEM_Activation_System +# Clone +git clone https://github.com/ChesnoTech/KeyGate.git +cd KeyGate cp .env.example .env # Edit with your passwords -docker compose up -d # Start backend stack + +# Start Docker stack +docker compose up -d # Frontend dev server cd FINAL_PRODUCTION_SYSTEM/frontend npm install npm run dev # http://localhost:5173 -``` - -## Branch Strategy -- `main` — production-ready, CI must pass -- `develop` — active development, merge to main when stable -- Feature branches — branch from `develop`, PR back to `develop` +# Run tests +npm test # 14 tests across 3 suites +``` ## Before Submitting a PR -- [ ] `cd FINAL_PRODUCTION_SYSTEM/frontend && npm test` — all tests pass -- [ ] `php -l your-file.php` — no syntax errors on changed PHP files -- [ ] Translations added to both `i18n/en.json` and `i18n/ru.json` -- [ ] New routes wrapped in `` -- [ ] Use `jsonResponse()` not `echo json_encode()` -- [ ] Use prepared statements for all SQL queries +- [ ] `cd frontend && npm test` — all 14 tests pass +- [ ] `npm run build` — no TypeScript errors +- [ ] `docker compose exec web php -l ` — PHP lint +- [ ] Docker stack starts cleanly +- [ ] New translations in `en.json` + `ru.json` +- [ ] New actions in `api-contracts.test.ts` +- [ ] No `echo json_encode()` — use `jsonResponse()` +- [ ] No `Get-WmiObject` — use `Get-CimInstance` +- [ ] Admin password remains `Admin2024!` in dev ## Code Style @@ -38,7 +99,14 @@ npm run dev # http://localhost:5173 | JSON response | `jsonResponse(['success' => true])` | `echo json_encode(...)` | | File includes | `require_once __DIR__ . '/../config.php'` | `require_once '../config.php'` | | Error messages | `'An error occurred'` + `error_log($e)` | `$e->getMessage()` to client | +| WMI (PS1) | `Get-CimInstance Win32_BaseBoard` | `Get-WmiObject Win32_BaseBoard` | +| Race conditions | `$pdo->beginTransaction()` | Check-then-insert without TX | ## Adding a New Feature See the full guide in [CLAUDE.md](CLAUDE.md#contributing-guide). + +## License + +KeyGate is licensed under the Business Source License 1.1. +See [LICENSE](LICENSE) for details. From de1ba623a13157990d4fdb558d5dc79c472fab64 Mon Sep 17 00:00:00 2001 From: Ayoub Mohamed Samir <7agtyadmin@gmail.com> Date: Tue, 24 Mar 2026 19:12:08 +0200 Subject: [PATCH 2/2] 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) --- .../activation/main_v3.PS1 | 331 +++++++++++++++++- 1 file changed, 329 insertions(+), 2 deletions(-) diff --git a/FINAL_PRODUCTION_SYSTEM/activation/main_v3.PS1 b/FINAL_PRODUCTION_SYSTEM/activation/main_v3.PS1 index dd91fb1..25d4b5b 100644 --- a/FINAL_PRODUCTION_SYSTEM/activation/main_v3.PS1 +++ b/FINAL_PRODUCTION_SYSTEM/activation/main_v3.PS1 @@ -2247,14 +2247,318 @@ function Invoke-TaskPipeline { return $results } +# ============================================================================ +# ============================================================================ +# === RESILIENCE: State Machine, Mutex, Connection Monitor, Boot Recovery === +# ============================================================================ + +$script:StateDir = "$env:ProgramData\KeyGate" +$script:StateFile = "$script:StateDir\activation-state.json" + +# ── Single Instance Mutex ──────────────────────────────────── +$script:InstanceMutex = $null +function Acquire-SingleInstanceLock { + $script:InstanceMutex = [System.Threading.Mutex]::new($false, "Global\KeyGate_Activation_Lock") + if (-not $script:InstanceMutex.WaitOne(0)) { + Write-Host "`n❌ KeyGate is already running on this PC." -ForegroundColor Red + Write-Host "Only one activation instance can run at a time." -ForegroundColor Yellow + Write-Host "If the previous instance crashed, delete: $script:StateFile" -ForegroundColor DarkGray + Start-Sleep -Seconds 3 + exit 1 + } +} + +function Release-SingleInstanceLock { + if ($script:InstanceMutex) { + try { $script:InstanceMutex.ReleaseMutex() } catch {} + $script:InstanceMutex.Dispose() + } +} + +# ── Persistent State File ──────────────────────────────────── +function Save-ActivationState { + param([hashtable]$State) + if (-not (Test-Path $script:StateDir)) { + New-Item -ItemType Directory -Path $script:StateDir -Force | Out-Null + } + $State['updated_at'] = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + $json = $State | ConvertTo-Json -Depth 5 + # Atomic write: write to temp then move (survives power cut during write) + $tempFile = "$script:StateFile.tmp" + [IO.File]::WriteAllText($tempFile, $json) + [IO.File]::Move($tempFile, $script:StateFile, $true) +} + +function Load-ActivationState { + if (Test-Path $script:StateFile) { + try { + $json = [IO.File]::ReadAllText($script:StateFile) + return $json | ConvertFrom-Json -AsHashtable + } catch { + Write-Host "⚠️ Corrupt state file, starting fresh" -ForegroundColor Yellow + Remove-Item $script:StateFile -Force -ErrorAction SilentlyContinue + } + } + return $null +} + +function Remove-ActivationState { + Remove-Item $script:StateFile -Force -ErrorAction SilentlyContinue + Remove-Item "$script:StateFile.tmp" -Force -ErrorAction SilentlyContinue +} + +# ── Connection Monitor ─────────────────────────────────────── +function Wait-ForConnection { + param( + [string]$Context = "server", + [int]$CheckIntervalSeconds = 2, + [int]$MaxWaitSeconds = 300 # 5 minutes max + ) + + $apiHost = ([uri]$APIBaseURL).Host + $elapsed = 0 + $wasDisconnected = $false + + while ($elapsed -lt $MaxWaitSeconds) { + # Quick connectivity check: try API health endpoint first, then ping fallback + $connected = $false + try { + $r = Invoke-WebRequest -Uri "$APIBaseURL/health.php" -Method HEAD -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop + if ($r.StatusCode -eq 200) { $connected = $true } + } catch { + # Fallback: ping + $connected = Test-Connection -ComputerName $apiHost -Count 1 -Quiet -ErrorAction SilentlyContinue + } + + if ($connected) { + if ($wasDisconnected) { + Write-Host "`r✅ Connection restored! Resuming... " -ForegroundColor Green + } + return $true + } + + $wasDisconnected = $true + $remaining = $MaxWaitSeconds - $elapsed + Write-Host "`r⏳ Connection lost. Retrying... (${remaining}s remaining) " -ForegroundColor Yellow -NoNewline + Start-Sleep -Seconds $CheckIntervalSeconds + $elapsed += $CheckIntervalSeconds + } + + Write-Host "`n❌ Connection timeout after ${MaxWaitSeconds}s" -ForegroundColor Red + return $false +} + +function Invoke-WithRetry { + param( + [scriptblock]$Action, + [string]$Description = "API call", + [int]$MaxRetries = 3, + [int]$RetryDelaySeconds = 2 + ) + + for ($i = 1; $i -le $MaxRetries; $i++) { + try { + $result = & $Action + return $result + } catch { + $isNetworkError = $_.Exception.Message -match 'Unable to connect|timeout|network|connection' + if ($isNetworkError -and $i -lt $MaxRetries) { + Write-Host " ⚠️ $Description failed (attempt $i/$MaxRetries): network error" -ForegroundColor Yellow + $reconnected = Wait-ForConnection -Context $Description -MaxWaitSeconds 60 + if (-not $reconnected) { + throw $_ + } + } elseif ($i -lt $MaxRetries) { + Write-Host " ⚠️ $Description failed (attempt $i/$MaxRetries): $($_.Exception.Message)" -ForegroundColor Yellow + Start-Sleep -Seconds $RetryDelaySeconds + } else { + throw $_ + } + } + } +} + +# ── Boot Recovery Scheduled Task ───────────────────────────── +function Register-BootRecoveryTask { + $taskName = "KeyGate_ActivationRecovery" + $stateFile = $script:StateFile + $apiBase = $APIBaseURL + + # PowerShell script that runs on boot to complete pending operations + $recoveryScript = @" +# KeyGate Boot Recovery — completes pending activation reporting +`$stateFile = '$stateFile' +if (-not (Test-Path `$stateFile)) { Unregister-ScheduledTask -TaskName '$taskName' -Confirm:`$false -EA SilentlyContinue; exit 0 } +`$state = Get-Content `$stateFile -Raw | ConvertFrom-Json +if (`$state.phase -eq 'activated' -or `$state.phase -eq 'key_installed') { + # Check actual Windows activation status + try { + `$status = (Get-CimInstance -Query "SELECT LicenseStatus FROM SoftwareLicensingProduct WHERE Name LIKE 'Windows%' AND PartialProductKey IS NOT NULL").LicenseStatus + `$result = if (`$status -eq 1) { 'success' } else { 'failed' } + `$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 + Invoke-RestMethod -Uri '$apiBase/report-result.php' -Method POST -Body `$body -ContentType 'application/json' -TimeoutSec 15 -EA Stop + } catch { Start-Sleep -Seconds 60; exit 1 } + Remove-Item `$stateFile -Force -EA SilentlyContinue +} +Unregister-ScheduledTask -TaskName '$taskName' -Confirm:`$false -EA SilentlyContinue +"@ + + $encodedCmd = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($recoveryScript)) + + try { + # Remove existing task if any + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCmd" + $trigger = New-ScheduledTaskTrigger -AtStartup + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest + + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null + Write-Host " 🔒 Boot recovery task registered" -ForegroundColor DarkGray + } catch { + Write-Host " ⚠️ Could not register boot recovery task: $_" -ForegroundColor Yellow + } +} + +function Unregister-BootRecoveryTask { + try { + Unregister-ScheduledTask -TaskName "KeyGate_ActivationRecovery" -Confirm:$false -ErrorAction SilentlyContinue + } catch {} +} + +# ── Resume from Previous State ─────────────────────────────── +function Resume-FromState { + param([hashtable]$State) + + $phase = $State['phase'] + Write-Host "`n🔄 Resuming from previous session (phase: $phase)" -ForegroundColor Cyan + Write-Host " Previous run: $($State['updated_at'])" -ForegroundColor DarkGray + + switch ($phase) { + 'authenticated' { + Write-Host " Re-authentication needed (token likely expired)" -ForegroundColor Yellow + return $null # Force re-auth + } + 'hw_submitted' { + Write-Host " Hardware already submitted. Proceeding to key request." -ForegroundColor Green + return @{ + resume_phase = 'get_key' + session_token = $State['session_token'] + order_number = $State['order_number'] + activation_id = $State['activation_id'] + } + } + 'key_installed' { + Write-Host " Key was installed. Checking activation status..." -ForegroundColor Green + # Check if Windows is already activated + try { + $licStatus = (Get-CimInstance -Query "SELECT LicenseStatus FROM SoftwareLicensingProduct WHERE Name LIKE 'Windows%' AND PartialProductKey IS NOT NULL").LicenseStatus + if ($licStatus -eq 1) { + Write-Host " ✅ Windows is activated! Just need to report to server." -ForegroundColor Green + return @{ + resume_phase = 'report_success' + session_token = $State['session_token'] + order_number = $State['order_number'] + activation_id = $State['activation_id'] + product_key = $State['product_key'] + } + } else { + Write-Host " Key installed but not activated. Retrying activation..." -ForegroundColor Yellow + return @{ + resume_phase = 'activate' + session_token = $State['session_token'] + order_number = $State['order_number'] + activation_id = $State['activation_id'] + product_key = $State['product_key'] + } + } + } catch { + Write-Host " Cannot check status. Will retry activation." -ForegroundColor Yellow + return @{ + resume_phase = 'activate' + session_token = $State['session_token'] + order_number = $State['order_number'] + activation_id = $State['activation_id'] + product_key = $State['product_key'] + } + } + } + 'activated' { + Write-Host " Activation succeeded! Reporting to server..." -ForegroundColor Green + return @{ + resume_phase = 'report_success' + session_token = $State['session_token'] + order_number = $State['order_number'] + activation_id = $State['activation_id'] + product_key = $State['product_key'] + } + } + default { + Write-Host " Unknown phase '$phase'. Starting fresh." -ForegroundColor Yellow + Remove-ActivationState + return $null + } + } +} + # ============================================================================ # === MAIN EXECUTION === # ============================================================================ -# Generate unique activation ID -$script:ActivationUniqueID = New-ActivationUniqueID +# Acquire single-instance lock FIRST +Acquire-SingleInstanceLock + +# Ensure cleanup on exit (normal or Ctrl+C) +$null = Register-EngineEvent PowerShell.Exiting -Action { + Release-SingleInstanceLock +} +trap { + Release-SingleInstanceLock + break +} + +# Check for pending state from a previous interrupted session +$previousState = Load-ActivationState +$resumeInfo = $null +if ($previousState) { + $resumeInfo = Resume-FromState -State $previousState +} + +# Generate unique activation ID (or reuse from previous state) +if ($resumeInfo -and $resumeInfo.activation_id) { + $script:ActivationUniqueID = $resumeInfo.activation_id +} else { + $script:ActivationUniqueID = New-ActivationUniqueID +} Write-Host "📋 Activation ID: $ActivationUniqueID" -ForegroundColor Cyan +# Handle resume: report success from previous session +if ($resumeInfo -and $resumeInfo.resume_phase -eq 'report_success') { + Write-Host "`n📤 Reporting previous activation result to server..." -ForegroundColor Cyan + try { + $reportBody = @{ + session_token = $resumeInfo.session_token + result = 'success' + attempt_number = 1 + activation_server = 'oem' + activation_unique_id = $resumeInfo.activation_id + notes = "Recovered after interruption. Key: $($resumeInfo.product_key.Substring(0,5))..." + } + Invoke-WithRetry -Description "Report result" -Action { + Invoke-APICall -Endpoint "report-result.php" -Body $reportBody + } + Write-Host "✅ Previous activation reported successfully!" -ForegroundColor Green + Remove-ActivationState + Unregister-BootRecoveryTask + Release-SingleInstanceLock + Start-Sleep -Seconds 3 + exit 0 + } catch { + Write-Host "⚠️ Could not report. Will retry on next run." -ForegroundColor Yellow + } +} + # Check activation status FIRST $alreadyActivated = $false try { @@ -2551,6 +2855,17 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) { } Write-Host "✅ Key retrieved: $($keyResponse.oem_identifier)" -ForegroundColor Green + + # Save state: key received (survives power loss) + Save-ActivationState @{ + phase = 'key_installed' + session_token = $keyResponse.session_token + order_number = $OrderNumber + activation_id = $script:ActivationUniqueID + product_key = $keyResponse.product_key + attempt_number = $keyAttempts + } + Register-BootRecoveryTask if ($keyResponse.key_status -eq 'retry' -and $keyResponse.fail_counter) { Write-Host "ℹ️ This key has $($keyResponse.fail_counter) previous failure(s)" -ForegroundColor Yellow } @@ -2562,6 +2877,12 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) { -OrderNumber $OrderNumber ` -ActivationUniqueID $script:ActivationUniqueID + if ($activationComplete) { + # Activation succeeded — clean up state and boot recovery + Remove-ActivationState + Unregister-BootRecoveryTask + } + if (-not $activationComplete) { # Clean up failed key before requesting a new one Write-Host "`n🔑 Cleaning up failed key..." -ForegroundColor Yellow @@ -2572,6 +2893,12 @@ while (-not $activationComplete -and $keyAttempts -lt $MaxKeysToTry) { } } +if ($activationComplete) { + Remove-ActivationState + Unregister-BootRecoveryTask + Release-SingleInstanceLock +} + if (-not $activationComplete) { if ($KeyExhaustionAction -eq 'retry_loop') { Write-Host "`n⚠️ All $MaxKeysToTry key(s) exhausted. Waiting $RetryCooldownSeconds seconds before retrying..." -ForegroundColor Yellow