src/Private/Test-OpteraLicenseKey.ps1
|
function Test-OpteraLicenseKey { <# .SYNOPSIS Determines the report reveal mode (Free vs Full) from the license key. .DESCRIPTION The scan always runs fully and locally; this only decides whether the report renderer may reveal the per-account remediation list (Full) or just the aggregate headline (Free). Resolution order: 1. No key (param or stored file) -> Free. 2. Malformed key -> Free (before any network call). 3. -Offline switch -> Full on a well-formed key, no network. Used by the offline fixture demo, Pester tests, and Unlock (which stores the key before any tenant is known). 4. Online -> POST the key + tenant id + product version to the Optera AI validation endpoint, which checks the key is active, bound to this tenant, and unexpired. The result (including any subscription expiry the endpoint returns) is cached; if the endpoint is unreachable, a cached Full result within -GraceDays keeps the customer working offline - but never past a known subscription expiry, so a lapsed subscription can't ride the grace window. Only the key, tenant id, and version leave - never directory data. .OUTPUTS PSCustomObject: Mode ('Free'|'Full'), KeyPresent, Validated, Message #> [CmdletBinding()] param( [string] $LicenseKey, [string] $TenantId, [switch] $Offline, [int] $GraceDays = 30 ) if (-not $LicenseKey) { $path = Get-OpteraLicenseKeyPath if (Test-Path -LiteralPath $path) { $LicenseKey = (Get-Content -LiteralPath $path -Raw).Trim() } } if (-not $LicenseKey) { return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $false; Validated = $false; Message = 'No license key found - running in free mode.' } } # Format gate (cheap, runs before any network). Real keys: OLR-XXXXX-XXXXX-XXXXX if ($LicenseKey -notmatch '^OLR-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$') { return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'License key is malformed - staying in free mode.' } } # Offline path: format-only acceptance (fixtures / tests / unlock-time storage). if ($Offline) { return [pscustomobject]@{ Mode = 'Full'; KeyPresent = $true; Validated = $false; Message = 'License key accepted (offline format check).' } } # Online validation, bound to the tenant. if (-not $TenantId) { return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'No tenant context to validate the license against - staying in free mode.' } } $body = @{ key = $LicenseKey; tenantId = $TenantId; version = $script:OpteraLicenseReclaimVersion } | ConvertTo-Json -Compress try { $resp = Invoke-RestMethod -Method Post -Uri $script:OpteraLicenseEndpoint -Body $body -ContentType 'application/json' -TimeoutSec 15 -ErrorAction Stop $mode = if ($resp.mode -eq 'Full') { 'Full' } else { 'Free' } $reason = if ($resp.PSObject.Properties.Name -contains 'reason') { $resp.reason } else { 'not valid' } # Subscription keys come back with an expiry (period end); perpetual unlock keys return null. $exp = if (($resp.PSObject.Properties.Name -contains 'exp') -and $resp.exp) { [string]$resp.exp } else { '' } Save-OpteraLicenseValidation -LicenseKey $LicenseKey -TenantId $TenantId -Mode $mode -Exp $exp $msg = if ($mode -eq 'Full') { 'License validated.' } else { "License not valid for this tenant ($reason)." } return [pscustomobject]@{ Mode = $mode; KeyPresent = $true; Validated = $true; Message = $msg } } catch { # Endpoint unreachable - fall back to a cached Full result within the grace window. $cached = Get-OpteraLicenseValidation -LicenseKey $LicenseKey -TenantId $TenantId -GraceDays $GraceDays if ($cached -and $cached.Mode -eq 'Full') { return [pscustomobject]@{ Mode = 'Full'; KeyPresent = $true; Validated = $false; Message = "Validation endpoint unreachable; using cached entitlement (checked $([int]((Get-Date) - $cached.CheckedAt).TotalDays)d ago)." } } return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'Could not reach the validation endpoint and no cached entitlement - staying in free mode.' } } } function Get-OpteraLicenseKeyPath { <# Returns the per-user path where the license key is stored. #> [CmdletBinding()] param() return Join-Path (Get-OpteraLicenseDir) 'license.key' } function Get-OpteraLicenseDir { <# Per-user Optera config dir (Windows %APPDATA%, else ~/.config). #> [CmdletBinding()] param() $appData = [Environment]::GetFolderPath('ApplicationData') if (-not $appData) { $appData = Join-Path $HOME '.config' } return Join-Path $appData 'Optera/LicenseReclaim' } function Save-OpteraLicenseValidation { <# Caches the last successful validation for offline grace. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $LicenseKey, [Parameter(Mandatory)] [string] $TenantId, [Parameter(Mandatory)] [string] $Mode, [string] $Exp ) $dir = Get-OpteraLicenseDir if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $record = [pscustomobject]@{ Key = $LicenseKey TenantId = $TenantId Mode = $Mode Exp = $Exp CheckedAt = (Get-Date).ToString('o') } $record | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $dir 'validation.json') -Encoding UTF8 } function Get-OpteraLicenseValidation { <# Reads the cached validation if it matches this key+tenant and is within -GraceDays. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $LicenseKey, [Parameter(Mandatory)] [string] $TenantId, [int] $GraceDays = 30 ) $path = Join-Path (Get-OpteraLicenseDir) 'validation.json' if (-not (Test-Path -LiteralPath $path)) { return $null } try { $rec = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json if ($rec.Key -ne $LicenseKey -or $rec.TenantId -ne $TenantId) { return $null } $checkedAt = [datetime]::Parse($rec.CheckedAt) if (((Get-Date) - $checkedAt).TotalDays -gt $GraceDays) { return $null } # A subscription key carries an expiry; never honor it past its known end, even inside the # grace window. Perpetual unlock keys store no expiry and skip this check. (Older cache files # predate the Exp field - treat them as no-expiry.) if (($rec.PSObject.Properties.Name -contains 'Exp') -and $rec.Exp) { try { if ((Get-Date) -gt [datetime]::Parse($rec.Exp)) { return $null } } catch { return $null } } return [pscustomobject]@{ Mode = $rec.Mode; CheckedAt = $checkedAt } } catch { return $null } } |