Private/NPSHandoff.ps1

<#
.SYNOPSIS
    NPS-Manager menu 7 - hand back to the Master Orchestrator (2026-09-30).
 
.DESCRIPTION
    Writes ONE file, <Company>_NPSResponse.json, for the tech to copy into the Orchestrator's
    Staging\<Abbrev>\ folder - the same one-file pattern as CA-Manager's <Company>_CAResponse.json.
    It carries this NPS server's facts (extension, number matching, RADIUS clients) and a copy of the
    live ias.xml for the Orchestrator's Resume Point D report (NSP.FortiGate -NpsConfig).
 
    The ias.xml copy has every secret removed first: RADIUS client shared secrets, remote RADIUS
    server authentication/accounting secrets, and any other *Secret / *Password / *Psk value. That's
    unconditional (the maintainer: "Remove the secrets"), and the written file is re-read and checked before
    menu 7 reports success. The report never reads those values, so nothing is lost.
 
    Read-only on this server: nothing in NPS is changed.
 
    Schema 1:
      SchemaVersion, Tool, ToolVersion, Generated, Company, ComputerName, Domain
      Nps RoleInstalled, ExtensionInstalled, ExtensionVersion, NumberMatchingOverride,
                    NetworkPolicyCount, ConnectionRequestPolicyCount
      RadiusClients Name, Address, Enabled (no secrets)
      IasXml FileName, LastWriteTime, SecretsRemoved, Sha256, ContentBase64
                    (the redacted file's exact bytes, in ias.xml's own encoding - UTF-16 LE + BOM)
#>


$script:NPSHandoffResponseSchema = 1

# Element names whose TEXT is a secret. Container elements of the same name (the schema section's
# <Accounting_Secret name=...><Properties>...) have child elements, not text, so they never match.
$script:NPSSecretElementPattern = '(?<open><(?<tag>[A-Za-z_]*(?:Secret|Password|Psk))\b[^>]*>)(?<value>[^<]*)(?<close></\k<tag>>)'
$script:NPSRedactedValue = '&lt;redacted&gt;'

# ---------------------------------------------------------------------------
function ConvertTo-NPSRedactedIasXml {
    <#
    .SYNOPSIS
        PURE. Replaces the text of every secret element in ias.xml content with <redacted>.
        Returns @{ Content; Count } - Count is how many non-empty secrets were removed.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$RawContent)

    $count = @(Get-NPSUnredactedSecret -RawContent $RawContent).Count
    $content = [regex]::Replace($RawContent, $script:NPSSecretElementPattern, {
        param($m)
        $value = $m.Groups['value'].Value
        if ([string]::IsNullOrWhiteSpace($value) -or $value -eq $script:NPSRedactedValue) { return $m.Value }
        $m.Groups['open'].Value + $script:NPSRedactedValue + $m.Groups['close'].Value
    })
    [pscustomobject]@{ Content = $content; Count = $count }
}

# ---------------------------------------------------------------------------
function Get-NPSUnredactedSecret {
    <#
    .SYNOPSIS
        PURE. The element names that still hold a secret value (anything but blank or <redacted>).
        Empty when the content is clean. Names only - never the values.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$RawContent)

    @(foreach ($m in [regex]::Matches($RawContent, $script:NPSSecretElementPattern)) {
        $value = $m.Groups['value'].Value
        if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne $script:NPSRedactedValue) { $m.Groups['tag'].Value }
    })
}

# ---------------------------------------------------------------------------
function New-NPSHandoffResponse {
    <#
    .SYNOPSIS
        Builds the Schema 1 hand-back object from an ias.xml path plus the dashboard's status facts.
        Throws if the redacted copy wouldn't parse or still holds a secret.
    .PARAMETER Status
        Get-NPSStatus output (optional - facts are left blank without it).
    .PARAMETER ExtensionVersion
        The NPS Extension's DisplayVersion (Get-NPSExtensionUninstallInfo), or blank.
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [Parameter(Mandatory)][string]$Company,
        $Status,
        [string]$ExtensionVersion,
        [string]$ToolVersion = $script:NPSManagerVersion,
        [datetime]$Now = (Get-Date)
    )

    $config = Read-NPSConfig -Path $IASConfigPath
    $redacted = ConvertTo-NPSRedactedIasXml -RawContent $config.RawContent
    $left = @(Get-NPSUnredactedSecret -RawContent $redacted.Content)
    if ($left.Count) { throw "Secrets still present after redaction ($(($left | Select-Object -Unique) -join ', ')) - nothing written." }
    try { [xml]$redacted.Content | Out-Null } catch { throw "The redacted ias.xml no longer parses - nothing written. $($_.Exception.Message)" }

    # Same encoding (and BOM) as the live file, so the copy is a normal ias.xml to any reader.
    [byte[]]$bytes = $config.Encoding.GetPreamble() + $config.Encoding.GetBytes($redacted.Content)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try { $hash = ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '') } finally { $sha.Dispose() }

    $clients = @(foreach ($c in @(Get-NPSClients -Path $IASConfigPath)) {
        [pscustomobject]@{ Name = "$($c.Name)"; Address = "$($c.IPAddress)"; Enabled = [bool]$c.Enabled }
    })

    $domain = try { [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Name } catch { "$env:USERDNSDOMAIN" }
    [pscustomobject][ordered]@{
        SchemaVersion = $script:NPSHandoffResponseSchema
        Tool          = 'NPS-Manager'
        ToolVersion   = "$ToolVersion"
        Generated     = $Now.ToString('s')
        Company       = $Company
        ComputerName  = "$env:COMPUTERNAME"
        Domain        = $domain
        Nps           = [pscustomobject][ordered]@{
            RoleInstalled                = if ($Status) { [bool]$Status.NPSRoleInstalled } else { $null }
            ExtensionInstalled           = if ($Status) { [bool]$Status.ExtensionInstalled } else { $null }
            ExtensionVersion             = "$ExtensionVersion"
            NumberMatchingOverride       = if ($Status -and $null -ne $Status.OverrideNumberMatching) { "$($Status.OverrideNumberMatching)" } else { '' }
            NetworkPolicyCount           = if ($Status) { $Status.PolicyCount } else { $null }
            ConnectionRequestPolicyCount = if ($Status) { $Status.ConnectionRequestPolicyCount } else { $null }
        }
        RadiusClients = $clients
        IasXml        = [pscustomobject][ordered]@{
            FileName       = Split-Path $IASConfigPath -Leaf
            LastWriteTime  = (Get-Item -LiteralPath $IASConfigPath).LastWriteTime.ToString('s')
            SecretsRemoved = $redacted.Count
            Sha256         = $hash
            ContentBase64  = [Convert]::ToBase64String($bytes)
        }
    }
}

# ---------------------------------------------------------------------------
function Write-NPSHandoffFile {
    <#
    .SYNOPSIS
        Writes <stem>_NPSResponse.json into OutputDir, then re-reads it and confirms the embedded
        ias.xml decodes, matches its hash, and holds no secret. A file that fails is deleted and the
        function throws. Returns the path.
    #>

    param(
        [Parameter(Mandatory)]$Response,
        [Parameter(Mandatory)][string]$OutputDir
    )
    if (-not (Test-Path -LiteralPath $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null }
    # NSP.NPS: written as an NPS Response hand-off (NSP.Toolkit shared header, payload unchanged) -
    # <Company>_NPS_Response.json, for the Orchestrator's Staging\<Abbrev>\Inbox\. The zip-era
    # <stem>_NPSResponse.json is only written when NSP.Toolkit is unavailable.
    $useHandoff = [bool](Get-Command New-NSPHandoff -ErrorAction SilentlyContinue)
    if ($useHandoff) {
        $handoff = New-NSPHandoff -Kind Response -Tool NPS -Company "$($Response.Company)" -Payload $Response `
            -PayloadSchema ([int]$Response.SchemaVersion) -ToolVersion "$($Response.ToolVersion)" -GeneratedBy "NSP.NPS $($Response.ToolVersion)" `
            -ComputerName "$($Response.ComputerName)" -Domain "$($Response.Domain)"
        $path = (Export-NSPHandoff -Handoff $handoff -Directory $OutputDir).FullName
    } else {
        $stem = ("$($Response.Company)" -replace '[^A-Za-z0-9]', '')
        $path = Join-Path $OutputDir "${stem}_NPSResponse.json"
        $Response | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding UTF8
    }

    $problem = $null
    try {
        $check = if ($useHandoff) { (Import-NSPHandoff -Path $path -Tool NPS -Kind Response).Payload } else { Get-Content -Path $path -Raw | ConvertFrom-Json }
        [byte[]]$bytes = [Convert]::FromBase64String($check.IasXml.ContentBase64)
        $sha = [System.Security.Cryptography.SHA256]::Create()
        try { $hash = ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '') } finally { $sha.Dispose() }
        if ($hash -ne $check.IasXml.Sha256) { $problem = 'the embedded ias.xml does not match its hash' }
        else {
            $reader = New-Object System.IO.StreamReader((New-Object System.IO.MemoryStream(, $bytes)), [System.Text.Encoding]::UTF8, $true)
            try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() }
            $left = @(Get-NPSUnredactedSecret -RawContent $text)
            if ($left.Count) { $problem = "secrets still present ($(($left | Select-Object -Unique) -join ', '))" }
        }
    } catch { $problem = "it could not be read back: $($_.Exception.Message)" }

    if ($problem) {
        Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
        throw "Hand-back check failed - $problem. The file was deleted."
    }
    $path
}

# ---------------------------------------------------------------------------
function Invoke-NPSMenuHandoff {
    <#
    .SYNOPSIS
        Menu 7. Asks the company (defaulting to the staged NPSAnswers.json) and output folder, writes
        the hand-back, and tells the tech where to copy it. Safe to re-run: the file is overwritten.
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [string]$ScriptRoot,
        $Status
    )
    Write-NPSHeader "Hand back to the Orchestrator"
    Write-Host "Writes <Company>_NPS_Response.json: this server's NPS facts and a copy of ias.xml with" -ForegroundColor Cyan
    Write-Host "every shared secret removed, for the Orchestrator's VPN access report (Resume Point D)." -ForegroundColor Cyan
    Write-Host "Nothing on this server is changed." -ForegroundColor Gray
    Write-Host ""

    if (-not (Test-Path -LiteralPath $IASConfigPath)) {
        Write-Host "ias.xml not found at $IASConfigPath - install/authorize NPS first (option 1)." -ForegroundColor Red
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    $baked = Get-NPSBakedInAnswers -ScriptRoot $ScriptRoot
    $defaultCompany = if ($baked -and $baked.CompanyName) { "$($baked.CompanyName)" } else { '' }
    $company = ''
    while (-not $company) {
        $prompt = if ($defaultCompany) { "Company name [$defaultCompany]" } else { "Company name (as in the Orchestrator)" }
        $company = ([string](Read-Host $prompt)).Trim()
        if (-not $company) { $company = $defaultCompany }
    }
    $stem = ($company -replace '[^A-Za-z0-9]', '')
    $defaultDir = if (Get-Command Get-NSPToolWorkPath -ErrorAction SilentlyContinue) { Get-NSPToolWorkPath -Tool NPS -Kind Responses } else { "C:\Admin\Handoff\$stem" }
    $outDir = ([string](Read-Host "Output folder [$defaultDir]")).Trim().Trim('"')
    if (-not $outDir) { $outDir = $defaultDir }

    try {
        if (-not $Status) { $Status = Get-NPSStatus -IASConfigPath $IASConfigPath }
        $ext = if (Get-Command Get-NPSExtensionUninstallInfo -ErrorAction SilentlyContinue) { Get-NPSExtensionUninstallInfo } else { $null }
        $response = New-NPSHandoffResponse -IASConfigPath $IASConfigPath -Company $company -Status $Status -ExtensionVersion $(if ($ext) { $ext.DisplayVersion } else { '' })
        $path = Write-NPSHandoffFile -Response $response -OutputDir $outDir
    } catch {
        Write-Host ""
        Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    Write-Host ""
    Write-Host "Written: $path" -ForegroundColor Green
    Write-Host (" ias.xml copy: {0} secret(s) removed, checked clean." -f $response.IasXml.SecretsRemoved) -ForegroundColor Gray
    Write-Host (" RADIUS clients: {0}" -f (@($response.RadiusClients | ForEach-Object { "$($_.Name) ($($_.Address))" }) -join ', ')) -ForegroundColor Gray
    Write-Host ""
    Write-Host "Copy this one file to the Orchestrator machine, into Staging\<Abbrev>\Inbox\, then run" -ForegroundColor Cyan
    Write-Host "Resume Point D. Re-run this menu whenever the NPS policies change." -ForegroundColor Cyan
    Read-Host "Press Enter to return to the menu" | Out-Null
}