Private/CAHealth.ps1

<#
.SYNOPSIS
    CA Manager - menu 9 health check. Aggregates the read-only probes from Get-CAStatus / the
    per-menu Test-* functions into a PASS/WARN/FAIL report, and offers a targeted fix for each
    non-PASS row that has a clean single-engine remedy.
 
.DESCRIPTION
    Get-CAHealthReport is read-only. Each returned row carries a Fix (or $null): a { Label; Run }
    where Run is a scriptblock that calls the relevant engine (dry-run aware via Invoke-CAStep) or
    prints "run menu N". Invoke-CAMenuHealth renders the report and, per non-PASS row with a Fix,
    asks before running it and re-probes that one row.
#>


# ---------------------------------------------------------------------------
function New-CAHealthRow {
    param(
        [Parameter(Mandatory)][string]$Id,
        [Parameter(Mandatory)][string]$Label,
        [ValidateSet('PASS', 'WARN', 'FAIL', 'SKIP')][string]$State = 'SKIP',
        [string]$Detail = '',
        $Fix = $null
    )
    [pscustomobject]@{ Id = $Id; Label = $Label; State = $State; Detail = $Detail; Fix = $Fix }
}

# ---------------------------------------------------------------------------
function Get-CAHealthReport {
    <#
    .SYNOPSIS
        Read-only. Returns an ordered [pscustomobject[]] of health rows. Nothing here writes.
    .PARAMETER SampleCertPath
        Optional path to a cert issued by this CA - enables the end-to-end
        `certutil -verify -urlfetch` (CRL + OCSP) check.
    #>

    param(
        [Parameter(Mandatory)]$CAAnswers,
        [Parameter(Mandatory)]$Status,
        [string]$SampleCertPath,
        # 2026-09-10, Part A item 8 - promotes menu 12's proven remote-client engines
        # (Get-CARenewalTemplateOid/Get-CARenewalTestCert, CARenewalTest.ps1) outward into the
        # health check, which had zero remote-client visibility before this. Both optional - SKIP
        # (not FAIL) when either is blank, same convention as -SampleCertPath above.
        [string]$RemoteClientComputerName,
        [string]$RemoteClientTemplateCn
    )
    $rows = New-Object System.Collections.Generic.List[object]

    # 1. CA service
    $svc = try { (Get-Service -Name CertSvc -ErrorAction Stop).Status } catch { $null }
    $rows.Add((New-CAHealthRow -Id 'ca-service' -Label 'AD CS service (CertSvc) running' `
        -State $(if ($svc -eq 'Running') { 'PASS' } elseif ($null -eq $svc) { 'SKIP' } else { 'FAIL' }) `
        -Detail "$svc" `
        -Fix $(if ($svc -and $svc -ne 'Running') { @{ Label = 'Start-Service CertSvc'; Run = {
            Invoke-CAStep -Description 'Start CertSvc' -Commands @('Start-Service CertSvc') -Action { Start-Service CertSvc } | Out-Null } } } else { $null })))

    # 2. custom templates published
    $tmplRows = @($Status.ExpectedTemplateStatus)
    $missing  = @($tmplRows | Where-Object { -not $_.Published })
    $rows.Add((New-CAHealthRow -Id 'templates' -Label 'Custom templates published on the CA' `
        -State $(if (-not $tmplRows.Count) { 'SKIP' } elseif (-not $missing.Count) { 'PASS' } else { 'FAIL' }) `
        -Detail $(if ($tmplRows.Count) { "$(($tmplRows | Where-Object Published).Count)/$($tmplRows.Count) published$(if ($missing.Count) { ' - missing: ' + ($missing.Name -join ', ') })" } else { 'could not read' }) `
        -Fix $(if ($missing.Count) { @{ Label = 'run menu 4 (Create / update certificate templates)'; Run = { Write-Host ' -> run menu 4, then menu 5 for permissions.' -ForegroundColor Cyan } } } else { $null })))

    # 3. auto-enrollment policy
    $rows.Add((New-CAHealthRow -Id 'autoenroll' -Label 'Auto-enrollment policy present (GPO applied)' `
        -State $(switch ($Status.AutoEnrollPolicyPresent) { $true { 'PASS' } $false { 'WARN' } default { 'SKIP' } }) `
        -Detail $(if ($Status.AutoEnrollPolicyPresent) { 'AEPolicy bit set' } else { 'not detected on this box (gpupdate may be pending, or (re)create the GPO via AD-Manager)' }) `
        -Fix $(if ($Status.AutoEnrollPolicyPresent -eq $false) { @{ Label = 'gpupdate /force (or (re)create the GPO via AD-Manager - moved there 2026-09-12, was this dashboard''s own menu 10)'; Run = {
            Invoke-CAStep -Description 'gpupdate /force' -Commands @('gpupdate /force') -Action { & gpupdate.exe /force | Out-String } -ContinueOnError | Out-Null } } } else { $null })))

    # 4. CDP / AIA routed through the App Proxy
    $rows.Add((New-CAHealthRow -Id 'cdp-aia' -Label 'CDP + AIA/OCSP routed through the App Proxy' `
        -State $(if ($Status.HasExternalCDP -and $Status.HasExternalAIAorOCSP) { 'PASS' } elseif ($null -eq $Status.HasExternalCDP) { 'SKIP' } else { 'FAIL' }) `
        -Detail ("CDP external {0}, AIA/OCSP external {1}" -f $Status.HasExternalCDP, $Status.HasExternalAIAorOCSP) `
        -Fix $(if (-not ($Status.HasExternalCDP -and $Status.HasExternalAIAorOCSP)) { @{ Label = 'run menu 8 (Route AIA / CDP / OCSP through the App Proxy)'; Run = { Write-Host ' -> run menu 8.' -ForegroundColor Cyan } } } else { $null })))

    # 5. CRL fresh + present locally
    $sanitized = try { Get-CAActiveConfigName } catch { $Status.CACommonName }
    $crlFile   = Join-Path $env:windir "System32\CertSrv\CertEnroll\$sanitized.crl"
    $crlState = 'SKIP'; $crlDetail = 'no local CRL file'
    if (Test-Path $crlFile) {
        $nextUpd = $null
        try {
            $dump = & certutil.exe -dump $crlFile 2>&1 | Out-String
            if ($dump -match 'NextUpdate:\s*(.+)') { $nextUpd = [datetime]::Parse($Matches[1].Trim()) }
        } catch { }
        if ($nextUpd) {
            $crlState  = if ((Get-Date) -lt $nextUpd) { 'PASS' } else { 'FAIL' }
            $crlDetail = "NextUpdate $($nextUpd.ToString('yyyy-MM-dd HH:mm'))"
        } else { $crlState = 'WARN'; $crlDetail = 'present but NextUpdate not parsed' }
    }
    $rows.Add((New-CAHealthRow -Id 'crl-fresh' -Label 'Base CRL present and not expired' -State $crlState -Detail $crlDetail `
        -Fix $(if ($crlState -in @('FAIL', 'WARN')) { @{ Label = 'certutil -crl (republish now)'; Run = {
            Invoke-CAStep -Description 'certutil -crl' -Commands @('certutil -crl') -Action { $o = & certutil.exe -crl 2>&1 | Out-String; if ($LASTEXITCODE -ne 0) { throw "certutil -crl exit $LASTEXITCODE`n$o" }; $o } -ContinueOnError | Out-Null } } } else { $null })))

    # 6. CRL reachable off-network (through the App Proxy)
    $crlUrl = if ($CAAnswers.CA_AppProxyCrlFqdn -and "$($CAAnswers.CA_AppProxyCrlFqdn)" -match '\.') {
        "http://$($CAAnswers.CA_AppProxyCrlFqdn)/CertEnroll/$sanitized.crl"
    } else { $null }
    $offState = 'SKIP'; $offDetail = 'CA_AppProxyCrlFqdn not resolved yet (menu 6)'
    if ($crlUrl) {
        try {
            $resp = Invoke-WebRequest -Uri $crlUrl -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
            $offState  = if ($resp.StatusCode -eq 200 -and $resp.RawContentLength -gt 0) { 'PASS' } else { 'WARN' }
            $offDetail = "HTTP $($resp.StatusCode), $($resp.RawContentLength) bytes"
        } catch {
            $offState  = 'FAIL'
            $offDetail = "$crlUrl -> $($_.Exception.Message)"
        }
    }
    $rows.Add((New-CAHealthRow -Id 'crl-offnet' -Label 'CRL reachable through the App Proxy (off-network)' -State $offState -Detail $offDetail `
        -Fix $(if ($offState -eq 'FAIL') { @{ Label = 'check menu 6 (App Proxy app) + menu 8 (URLs); a fresh App Proxy app needs ~10-15 min to provision'; Run = { Write-Host ' -> re-run menu 6, then menu 8.' -ForegroundColor Cyan } } } else { $null })))

    # 7. OCSP responder answering
    $ocspWorking = $false
    if (Get-Command Test-CAOcspEndpoint -ErrorAction SilentlyContinue) {
        $ocspWorking = Test-CAOcspEndpoint -Url 'http://localhost/ocsp' -RetrySeconds 10
    }
    $rows.Add((New-CAHealthRow -Id 'ocsp' -Label 'OCSP responder (/ocsp) answering' `
        -State $(if (-not $Status.OCSPRoleInstalled) { 'SKIP' } elseif ($ocspWorking) { 'PASS' } else { 'FAIL' }) `
        -Detail $(if (-not $Status.OCSPRoleInstalled) { 'role not installed' } elseif ($ocspWorking) { 'answered' } else { 'no answer on http://localhost/ocsp' }) `
        -Fix $(if ($Status.OCSPRoleInstalled -and -not $ocspWorking) { @{ Label = 're-push the config (Publish-CAOcspConfig) - or run menu 9'; Run = {
            if (Get-Command Publish-CAOcspConfig -ErrorAction SilentlyContinue) { Publish-CAOcspConfig } else { Write-Host ' -> run menu 9.' -ForegroundColor Cyan } } } } else { $null })))

    # 8. App Proxy connector
    $rows.Add((New-CAHealthRow -Id 'connector' -Label 'App Proxy connector running' `
        -State $(if ($Status.AppProxyConnectorStatus -eq 'Running') { 'PASS' } elseif (-not $Status.AppProxyConnectorInstalled) { 'SKIP' } else { 'FAIL' }) `
        -Detail $(if ($Status.AppProxyConnectorInstalled) { "$($Status.AppProxyConnectorStatus)" } else { 'not installed' }) `
        -Fix $(if ($Status.AppProxyConnectorInstalled -and $Status.AppProxyConnectorStatus -ne 'Running') { @{ Label = 'Start-Service WAPCSvc'; Run = {
            Invoke-CAStep -Description 'Start WAPCSvc' -Commands @('Start-Service WAPCSvc') -Action { Start-Service WAPCSvc } -ContinueOnError | Out-Null } } } else { $null })))

    # 9. end-to-end verify (optional - needs a real leaf cert)
    if ($SampleCertPath -and (Test-Path $SampleCertPath) -and (Get-Command Test-CAOcspEndpoint -ErrorAction SilentlyContinue)) {
        $ok = Test-CAOcspEndpoint -IssuedCertPath $SampleCertPath
        $rows.Add((New-CAHealthRow -Id 'verify' -Label "certutil -verify -urlfetch ($([System.IO.Path]::GetFileName($SampleCertPath)))" `
            -State $(if ($ok) { 'PASS' } else { 'WARN' }) `
            -Detail $(if ($ok) { 'revocation check passed via CRL/OCSP' } else { 'did not confirm - inspect certutil -verify -urlfetch output manually' })))
    } else {
        $rows.Add((New-CAHealthRow -Id 'verify' -Label 'End-to-end certutil -verify -urlfetch' -State 'SKIP' -Detail 'pass -SampleCertPath <a cert issued by this CA> to run this'))
    }

    # 10. remote client actually holds a cert from this CA (optional - promoted from menu 12, item 8).
    # Needs WinRM reachable from wherever CA-Manager runs to the target client. A FortiGate's DEFAULT
    # posture only allows client-initiated traffic and does NOT create a reverse (CA -> client) rule,
    # so this may not answer at all over a real field VPN tunnel without that explicitly configured -
    # this check can't tell "no reverse rule" apart from "genuinely no cert issued yet" on its own; a
    # FAIL here is a starting point to investigate, not a definitive verdict either way.
    if ($RemoteClientComputerName -and $RemoteClientTemplateCn) {
        $label = "Remote client '$RemoteClientComputerName' holds a cert from '$RemoteClientTemplateCn'"
        if (-not (Get-Command Get-CARenewalTemplateOid -ErrorAction SilentlyContinue) -or -not (Get-Command Get-CARenewalTestCert -ErrorAction SilentlyContinue)) {
            $rows.Add((New-CAHealthRow -Id 'remote-cert' -Label $label -State 'SKIP' -Detail 'CARenewalTest.ps1 (Get-CARenewalTemplateOid/Get-CARenewalTestCert) not loaded'))
        } else {
            try {
                $oid  = Get-CARenewalTemplateOid -TemplateCn $RemoteClientTemplateCn
                $cert = Get-CARenewalTestCert -TemplateOid $oid -ComputerName $RemoteClientComputerName
                if (-not $cert) { $cert = Get-CARenewalTestCert -TemplateOid $oid -ComputerName $RemoteClientComputerName -StoreLocation LocalMachine }
                $rows.Add((New-CAHealthRow -Id 'remote-cert' -Label $label `
                    -State $(if ($cert) { 'PASS' } else { 'FAIL' }) `
                    -Detail $(if ($cert) { "thumbprint $($cert.Thumbprint), expires $($cert.NotAfter.ToString('yyyy-MM-dd'))" } else { 'no matching cert in CurrentUser or LocalMachine store - not necessarily a real problem, see the note above about FortiGate reverse rules' })))
            } catch {
                $rows.Add((New-CAHealthRow -Id 'remote-cert' -Label $label -State 'FAIL' -Detail "could not check: $($_.Exception.Message)"))
            }
        }
    } else {
        $rows.Add((New-CAHealthRow -Id 'remote-cert' -Label 'Remote client holds an issued VPN cert (optional)' -State 'SKIP' -Detail 'pass -RemoteClientComputerName + -RemoteClientTemplateCn to check a specific client'))
    }

    $rows.ToArray()
}

# ---------------------------------------------------------------------------
function Show-CAHealthReport {
    param([Parameter(Mandatory)]$Report)
    Write-Host ""
    foreach ($r in $Report) {
        $c = switch ($r.State) { 'PASS' { 'Green' } 'WARN' { 'Yellow' } 'FAIL' { 'Red' } default { 'DarkGray' } }
        Write-Host (" [{0,-4}] {1,-48} {2}" -f $r.State, $r.Label, $r.Detail) -ForegroundColor $c
    }
    $fail = @($Report | Where-Object State -eq 'FAIL').Count
    $warn = @($Report | Where-Object State -eq 'WARN').Count
    Write-Host ""
    Write-Host (" {0} PASS / {1} WARN / {2} FAIL / {3} SKIP" -f `
        @($Report | Where-Object State -eq 'PASS').Count, $warn, $fail, @($Report | Where-Object State -eq 'SKIP').Count) `
        -ForegroundColor $(if ($fail) { 'Red' } elseif ($warn) { 'Yellow' } else { 'Green' })
}

# ---------------------------------------------------------------------------
function Invoke-CAMenuHealth {
    <#
    .SYNOPSIS
        Menu 11 (2026-09-10 renumber - was menu 9). Runs Get-CAHealthReport, prints it, then offers
        each non-PASS row's fix. Optionally checks a real VPN client (over PS remoting) for an
        actually-issued cert from a named template - promoted from menu 12's proven remote-client
        engines (Part A item 8); needs WinRM reachable from this box to the client.
    #>

    param(
        $CAAnswers,
        [Parameter(Mandatory)]$Status
    )
    Write-CAHeader "Health check"
    if (-not $CAAnswers) { $CAAnswers = [pscustomobject]@{} }

    if (-not $Status.CACommonName) {
        Write-Host " No local CA detected - run menu 2 on the CA box." -ForegroundColor Yellow
        Read-Host "`nPress Enter to return to the menu" | Out-Null; return
    }

    $sample = Read-Host " Path to a cert issued by this CA for the end-to-end verify (optional, Enter to skip)"

    # Remote-client visibility (2026-09-10, Part A item 8) - both blank is the common case and just
    # SKIPs that one row; note up front that this needs WinRM reachable from this box to the client
    # (a FortiGate's default posture doesn't open a reverse rule for it - see the row's own detail
    # text if it FAILs).
    $remoteClient = Read-Host " VPN client hostname to verify has a real issued cert (optional, Enter to skip)"
    $remoteTemplateCn = $null
    if (-not [string]::IsNullOrWhiteSpace($remoteClient)) {
        $defaultCn = if (-not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TemplateAuto)) { ($CAAnswers.CA_TemplateAuto -replace '[^A-Za-z0-9]', '') } else { 'IKEv2VPNInternalUsers' }
        $remoteTemplateCn = Read-Host " Template CN to check for on that client [$defaultCn]"
        if ([string]::IsNullOrWhiteSpace($remoteTemplateCn)) { $remoteTemplateCn = $defaultCn }
    }

    $report = Get-CAHealthReport -CAAnswers $CAAnswers -Status $Status -SampleCertPath $sample -RemoteClientComputerName $remoteClient -RemoteClientTemplateCn $remoteTemplateCn
    Show-CAHealthReport -Report $report

    $fixable = @($report | Where-Object { $_.State -in @('FAIL', 'WARN') -and $_.Fix })
    if (-not $fixable.Count) {
        Write-Host ""
        Write-Host " Nothing to fix from here." -ForegroundColor Gray
        Read-Host "`nPress Enter to return to the menu" | Out-Null; return
    }

    foreach ($row in $fixable) {
        Write-Host ""
        Write-Host (" [{0}] {1}" -f $row.State, $row.Label) -ForegroundColor Yellow
        Write-Host (" fix: {0}" -f $row.Fix.Label) -ForegroundColor Gray
        if (Read-CAConfirm -Prompt " attempt this fix?") {
            try { & $row.Fix.Run } catch { Write-Host " fix errored: $($_.Exception.Message)" -ForegroundColor Red }
            Start-Sleep -Seconds 2
            $re = Get-CAHealthReport -CAAnswers $CAAnswers -Status $Status -SampleCertPath $sample -RemoteClientComputerName $remoteClient -RemoteClientTemplateCn $remoteTemplateCn | Where-Object Id -eq $row.Id
            if ($re) { Write-Host (" now: [{0}] {1}" -f $re.State, $re.Detail) -ForegroundColor $(if ($re.State -eq 'PASS') { 'Green' } else { 'Yellow' }) }
        }
    }
    Read-Host "`nPress Enter to return to the menu" | Out-Null
}