Private/CACore.ps1
|
<#
.SYNOPSIS Shared engine for CA Manager - read-only status probes of a live AD CS deployment, console helpers, and (in later phases) the CA/OCSP/GPO/App-Proxy mutation functions. .DESCRIPTION Phase 1 content only: Set-ConsoleFullScreen, Write-CAHeader, Get-CAStatus / Show-CAStatus, and the Invoke-CANotYetImplemented stub. Every probe in Get-CAStatus is individually wrapped and returns $null on any failure - $null means "couldn't determine", distinct from $false. The full one-shot inventory lives in the sibling Get-CAManagerInventory.ps1 (and the Entra side in Get-CAManagerEntraProxyInventory.ps1); Get-CAStatus is the lean always-on subset the dashboard header renders on every redraw. #> # --------------------------------------------------------------------------- function Set-ConsoleFullScreen { <# .SYNOPSIS Best-effort maximizes THIS console window. Verbatim copy of NPSManager\Modules\NPSCore.ps1's own Set-ConsoleFullScreen (kept in both trees since neither dot-sources the other). .DESCRIPTION UI Automation's WindowPattern.SetWindowVisualState(Maximized) - deliberately NOT a P/Invoke / Add-Type DllImport block (that pattern was the confirmed sole cause of 2 of 7 ATT&CK capabilities a real Microsoft Defender for Endpoint alert flagged against IPSec-MasterOrchestrator.ps1 - "Native API"/T1106, "Dynamic API Resolution"/T1027.007). UIAutomationClient/UIAutomationTypes are standard signed .NET Framework assemblies. FocusedElement resolves to a CHILD control inside the console that doesn't support WindowPattern, so this walks UP the automation tree until a Window-type ancestor is found, then calls WindowPattern on THAT. Wrapped so it can't throw or block - a host with no real console, no UI Automation, or pwsh/.NET just stays whatever size it already was. #> try { Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop $focused = [System.Windows.Automation.AutomationElement]::FocusedElement if ($focused) { $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker $current = $focused $windowElement = $null $hops = 0 while ($current -and $hops -lt 15) { if ($current.Current.ControlType -eq [System.Windows.Automation.ControlType]::Window) { $windowElement = $current break } $current = $walker.GetParent($current) $hops++ } if ($windowElement) { $patternObj = $null if ($windowElement.TryGetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern, [ref]$patternObj)) { ([System.Windows.Automation.WindowPattern]$patternObj).SetWindowVisualState([System.Windows.Automation.WindowVisualState]::Maximized) } } } } catch { # Best-effort only. } } # --------------------------------------------------------------------------- function Write-CAHeader { <# .SYNOPSIS Draws the "====" banner at the top of every screen and clears the screen first so scrollback from the previous screen doesn't pile up between transitions. Same shape as NPS Manager's Write-NPSHeader. #> param([string]$Title) try { Clear-Host } catch {} Write-Host "" Write-Host "======================================================" -ForegroundColor Cyan Write-Host " $Title" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan Write-Host "" } # --------------------------------------------------------------------------- function Get-CAIEEnhancedSecurity { <# .SYNOPSIS Returns @{ Admin; User } - $true when IE Enhanced Security Configuration is ON for that class, $null if the key can't be read (not a Server SKU / not present). #> $keys = @{ Admin = 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' User = 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' } $out = @{} foreach ($k in $keys.Keys) { try { $out[$k] = ((Get-ItemProperty $keys[$k] -Name IsInstalled -ErrorAction Stop).IsInstalled -eq 1) } catch { $out[$k] = $null } } $out } function Set-CAIEEnhancedSecurity { <# .SYNOPSIS Turns IE Enhanced Security Configuration off (default) or on, for Admins and Users - the same two Active Setup IsInstalled flags Server Manager's toggle flips. MSAL's embedded browser (Connect-MgGraph, the connector installer's sign-in) goes blank on a server while ESC is on. Routes through Invoke-CAStep. Restarts explorer so it takes effect this session unless -NoExplorerRestart. #> param( [bool]$Enabled = $false, [switch]$NoExplorerRestart ) $val = if ($Enabled) { 1 } else { 0 } $state = if ($Enabled) { 'ON' } else { 'OFF' } $keys = @( 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' # Administrators 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' # Users ) Invoke-CAStep -Description "Turn IE Enhanced Security Configuration $state (Admins + Users)" ` -Commands @($keys | ForEach-Object { "Set-ItemProperty '$_' -Name IsInstalled -Value $val" }) ` -Action { foreach ($k in $keys) { if (Test-Path $k) { Set-ItemProperty -Path $k -Name IsInstalled -Value $val -Type DWord -Force } } } -ContinueOnError | Out-Null if (-not $NoExplorerRestart -and -not (Get-CADryRun)) { Invoke-CAStep -Description "Restart explorer so the ESC change applies to this session" ` -Commands @('Stop-Process -Name explorer -Force (auto-restarts)') ` -Action { Get-Process -Name explorer -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 if (-not (Get-Process -Name explorer -ErrorAction SilentlyContinue)) { Start-Process explorer.exe } } -ContinueOnError | Out-Null } } # --------------------------------------------------------------------------- function Invoke-CANotYetImplemented { <# .SYNOPSIS Phase-1 placeholder for a dashboard action whose engine lands in a later phase. Mirrors NPS Manager's own Invoke-NotYetBuilt. #> param( [Parameter(Mandatory)][string]$Feature, [int]$Phase ) Write-CAHeader $Feature $when = if ($Phase) { "planned for Phase $Phase" } else { "planned for a later build" } Write-Host "Not built yet - $when of the CA Manager build plan." -ForegroundColor Yellow Write-Host "" Write-Host "For now, use Get-CAManagerInventory.ps1 (menu option R) for a full read-only" -ForegroundColor Gray Write-Host "picture of this deployment, and follow the by-hand runbook." -ForegroundColor Gray Read-Host "Press Enter to return to the menu" | Out-Null } # --------------------------------------------------------------------------- # DRY RUN engine - extracted 2026-09-10 (Part A item 9) into a shared, tool-agnostic file so it's # reusable outside CA-Manager, not just here. Invoke-CAStep/Get-CADryRun/Set-CADryRun (still under # their existing CA-prefixed names - see the shared file's own header for why) come from there now; # same "zip layout, then repo layout" candidate lookup already proven by Modules\CATestSuite.ps1's # own Request-VPNCertCore.ps1 dot-source. Every mutating engine below (CAInstall / CATemplates / # CAAutoEnrollGPO / CAUrls / ...) still just calls Invoke-CAStep exactly as before - only WHERE it's # defined moved, not its name or behavior, so no other file in this tool needed to change at all. # --------------------------------------------------------------------------- $script:__caDryRunEngineLoaded = $false # NSP.PKI: the module ships it as (only) Private\Engine\DryRunEngine.ps1. foreach ($candidate in @( (Join-Path $PSScriptRoot "Engine\DryRunEngine.ps1") )) { if (Test-Path $candidate) { . $candidate; $script:__caDryRunEngineLoaded = $true; break } } if (-not $script:__caDryRunEngineLoaded) { throw "DryRunEngine.ps1 was not found (expected next to CA-Manager.ps1 in a built CAManager.zip, or under IPSEC AIO\MiscTools\Shared\ when running from the repo) - CA-Manager cannot run without it." } # --------------------------------------------------------------------------- function Invoke-CertUtilText { <# .SYNOPSIS Runs certutil with the given args and returns combined stdout/stderr as one string, or $null if certutil isn't present / the call blew up. Never throws. #> param([Parameter(Mandatory)][string[]]$Arguments) try { if (-not (Get-Command certutil.exe -ErrorAction SilentlyContinue)) { return $null } $out = & certutil.exe @Arguments 2>&1 | Out-String return $out } catch { return $null } } # --------------------------------------------------------------------------- function ConvertFrom-CAPublicationUrls { <# .SYNOPSIS Parses a `certutil -getreg CA\CRLPublicationURLs` / `...\CACertPublicationURLs` dump into an array of [pscustomobject]@{ Flags; Url; RoutesExternally }. RoutesExternally is a heuristic: an http(s):// URL whose host is neither a %1-style placeholder nor localhost - i.e. one that actually leaves the box (the App Proxy / external CDP/AIA/OCSP hostnames). #> param([string]$DumpText) if ([string]::IsNullOrWhiteSpace($DumpText)) { return @() } $results = @() foreach ($line in ($DumpText -split "`r?`n")) { # e.g. " 5: 6:http://CRL-contoso.msappproxy.net/CertEnroll/%3%8%9.crl" if ($line -match '^\s*\d+:\s*(\d+):(\S.*)$') { $flags = [int]$Matches[1] $url = $Matches[2].Trim() $ext = $false if ($url -match '^(?i)https?://([^/]+)') { $urlHost = $Matches[1] if ($urlHost -notmatch '%\d' -and $urlHost -notmatch '^(?i)(localhost|127\.0\.0\.1)') { $ext = $true } } $results += [pscustomobject]@{ Flags = $flags; Url = $url; RoutesExternally = $ext } } } return $results } # --------------------------------------------------------------------------- function Get-CAStatus { <# .SYNOPSIS Read-only snapshot of the local AD CS deployment for the dashboard header. Every field is $null-tolerant. Nothing here writes, publishes, or revokes anything. #> param( [string]$CAConfigName, # Template display names this deployment is expected to carry (from CAAnswers.json when # available). Defaults to the standard IKEv2-VPN set (2026-09-15: "InternalUsers", not an # earlier client-specific "CorpLAN"); some clients also add their own OCSP response-signing template. [string[]]$ExpectedTemplates = @('IKEv2VPN-InternalUsers', 'IKEv2VPN-InternalUsers-MANUAL', 'FortiGate') ) $s = [ordered]@{ CAConfigName = $CAConfigName ADCSRoleInstalled = $null CACommonName = $null CAType = $null CAIsSubordinate = $null ParentCAConfig = $null CACertNotAfter = $null CACertDaysLeft = $null OCSPRoleInstalled = $null OCSPServiceRunning = $null OCSPRevocationConfigCount = $null PublishedTemplateCount = $null ExpectedTemplateStatus = @() CDPUrls = @() AIAUrls = @() HasExternalCDP = $null HasExternalAIAorOCSP = $null AutoEnrollPolicyPresent = $null UmbrellaGroup = $null CRLShareConfigured = $null CRLSharePath = $null CRLShareWriterGroup = $null AppProxyConnectorInstalled = $null AppProxyConnectorStatus = $null IISInstalled = $null CertEnrollVDirPresent = $null IssuanceReady = $false Notes = @() } # --- ADCS role --- try { if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) { $f = Get-WindowsFeature -Name ADCS-Cert-Authority -ErrorAction Stop $s.ADCSRoleInstalled = [bool]$f.Installed } } catch { } # --- CA identity (certutil -cainfo) --- $caInfo = Invoke-CertUtilText -Arguments @('-cainfo') if ($caInfo) { if ($caInfo -match '(?m)^\s*CA name:\s*(.+?)\s*$') { $s.CACommonName = $Matches[1] } if ($caInfo -match '(?m)^\s*CA type:\s*\d+\s*--\s*(.+?)\s*$') { $s.CAType = $Matches[1] $s.CAIsSubordinate = ($Matches[1] -match 'Subordinate') } } if (-not $s.CAConfigName) { $s.CAConfigName = $s.CACommonName } # --- parent CA (sub-CA only) --- $parentMachine = Invoke-CertUtilText -Arguments @('-getreg', 'CA\ParentCAMachine') $parentName = Invoke-CertUtilText -Arguments @('-getreg', 'CA\ParentCAName') if ($parentMachine -match '(?m)ParentCAMachine\s+REG_SZ\s*=\s*(.+?)\s*$' -and $parentMachine -notmatch 'EMPTY') { $pm = $Matches[1] $pn = if ($parentName -match '(?m)ParentCAName\s+REG_SZ\s*=\s*(.+?)\s*$') { $Matches[1] } else { $null } $s.ParentCAConfig = if ($pn) { "$pm\$pn" } else { $pm } } # --- CA cert expiry --- try { if ($s.CACommonName) { $pat = [regex]::Escape("CN=$($s.CACommonName)") $caCert = Get-ChildItem Cert:\LocalMachine\CA, Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.Subject -match $pat -and ($_.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | ForEach-Object { $_.CertificateAuthority }) -contains $true } | Sort-Object NotAfter -Descending | Select-Object -First 1 if ($caCert) { $s.CACertNotAfter = $caCert.NotAfter $s.CACertDaysLeft = [int][math]::Round(($caCert.NotAfter - (Get-Date)).TotalDays) } } } catch { } # --- OCSP --- try { if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) { $of = Get-WindowsFeature -Name ADCS-Online-Cert -ErrorAction Stop $s.OCSPRoleInstalled = [bool]$of.Installed } } catch { } try { $svc = Get-Service -Name OCSPSvc -ErrorAction Stop $s.OCSPServiceRunning = ($svc.Status -eq 'Running') } catch { } try { $rk = 'HKLM:\SYSTEM\CurrentControlSet\Services\OCSPSvc\Responder' if (Test-Path $rk) { $s.OCSPRevocationConfigCount = @(Get-ChildItem -Path $rk -ErrorAction SilentlyContinue).Count } } catch { } # --- templates published on this CA --- $catmpl = Invoke-CertUtilText -Arguments @('-CATemplates') $names = @() if ($catmpl) { foreach ($line in ($catmpl -split "`r?`n")) { # e.g. "IKEv2VPN-CorpLAN: IKEv2 VPN - Corp LAN -- Auto-Enroll: Access is denied." # The trailing "Access is denied." is a NORMAL per-template annotation (the running user # just can't auto-enroll that one) - it is NOT a command failure. if ($line -match '^\s*([A-Za-z0-9][A-Za-z0-9 ._-]*):\s.*--') { $names += $Matches[1].Trim() } } } # certutil quirk: on a CA with ZERO templates published (LoadDefaultTemplates=0), `-CATemplates` # returns "command FAILED: 0x80070490 (ERROR_NOT_FOUND) / Element not found" - that is a valid # "nothing published" answer, not a read failure. A GENUINE read failure is RPC-unavailable, no # CA on the box, or an empty result. $catmplNoneYet = $catmpl -match '(?i)(0x80070490|ERROR_NOT_FOUND|Element not found)' $catmplHardFail = [string]::IsNullOrWhiteSpace($catmpl) -or ($catmpl -match '(?i)(RPC server is unavailable|0x800706ba|cannot find the file specified|0x80070002)') $catmplOk = $catmplNoneYet -or (-not $catmplHardFail) if ($catmplNoneYet) { $names = @() } if ($catmplOk) { $s.PublishedTemplateCount = $names.Count } # `certutil -CATemplates` reports the template CN (first token). Menu 4 builds each CN by # stripping non-alphanumerics from the display name (IKEv2VPN-CorpLAN -> IKEv2VPNCorpLAN), so an # expected DISPLAY name won't literally equal a reported CN. Match on the stripped form too. $normPub = @($names | ForEach-Object { ($_ -replace '[^A-Za-z0-9]', '') }) $s.ExpectedTemplateStatus = foreach ($t in $ExpectedTemplates) { $tn = ($t -replace '[^A-Za-z0-9]', '') [pscustomobject]@{ Name = $t; Published = ($catmplOk -and (($names -contains $t) -or ($normPub -contains $tn))) } } # --- CDP / AIA / OCSP publication URLs --- $s.CDPUrls = ConvertFrom-CAPublicationUrls (Invoke-CertUtilText -Arguments @('-getreg', 'CA\CRLPublicationURLs')) $s.AIAUrls = ConvertFrom-CAPublicationUrls (Invoke-CertUtilText -Arguments @('-getreg', 'CA\CACertPublicationURLs')) if ($s.CDPUrls.Count) { $s.HasExternalCDP = [bool]($s.CDPUrls | Where-Object RoutesExternally) } if ($s.AIAUrls.Count) { # AIA dump carries both the CA-cert URLs and the OCSP URL (flags 2 vs 32) - either external # entry means the external retrieval path exists. $s.HasExternalAIAorOCSP = [bool]($s.AIAUrls | Where-Object RoutesExternally) } # --- auto-enroll policy (cheap live-registry proxy; GPO module may be absent on a member server) --- try { $aeVal = $null foreach ($hive in 'HKCU:', 'HKLM:') { $p = "$hive\Software\Policies\Microsoft\Cryptography\AutoEnrollment" if (Test-Path $p) { $v = (Get-ItemProperty -Path $p -Name AEPolicy -ErrorAction SilentlyContinue).AEPolicy if ($null -ne $v) { $aeVal = $v; break } } } if ($null -ne $aeVal) { $s.AutoEnrollPolicyPresent = ($aeVal -band 0x1) -eq 0x1 } elseif (-not (Get-Module -ListAvailable -Name GroupPolicy -ErrorAction SilentlyContinue)) { $s.Notes += "Auto-enroll GPO state unknown: GroupPolicy module not present on this box (normal for a member-server CA); no local AEPolicy value applied here either." } else { $s.AutoEnrollPolicyPresent = $false } } catch { } # --- umbrella group (from the auto template's AutoEnroll ACE) --- try { $autoTmpl = ($s.ExpectedTemplateStatus | Where-Object { $_.Published -and $_.Name -notmatch 'MANUAL' } | Select-Object -First 1).Name if ($autoTmpl -and (Get-Command Get-ADRootDSE -ErrorAction SilentlyContinue)) { $configNC = (Get-ADRootDSE -ErrorAction Stop).configurationNamingContext $dn = "CN=$autoTmpl,CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC" $autoEnrollGuid = [guid]'a05b8cc2-17bc-4802-a710-e7c15ab866a2' $acl = Get-Acl -Path ("AD:\" + $dn) -ErrorAction Stop $ace = $acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.ActiveDirectoryRights -match 'ExtendedRight' -and $_.ObjectType -eq $autoEnrollGuid -and $_.IdentityReference -notmatch '(?i)\\(Domain Admins|Enterprise Admins|Domain Users|Authenticated Users)$' } | Select-Object -First 1 if ($ace) { $s.UmbrellaGroup = "$($ace.IdentityReference)" } } } catch { } # --- CRL distribution share (from an active UNC / file:// publish entry) --- try { $shareEntry = $s.CDPUrls | Where-Object { ($_.Flags -band 0x1) -and ($_.Url -match '^(?i)(file://\\\\|\\\\)' -or $_.Url -match '^(?i)file://[a-z]:/') } | Select-Object -First 1 if ($shareEntry) { $s.CRLShareConfigured = $true $p = $shareEntry.Url -replace '^(?i)file://', '' $p = $p -replace '[\\/][^\\/]*%\d.*$', '' $s.CRLSharePath = $p if ($p -match '^\\\\' -and (Get-Command Get-Acl -ErrorAction SilentlyContinue)) { $acl = Get-Acl -LiteralPath $p -ErrorAction SilentlyContinue if ($acl) { $w = $acl.Access | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.FileSystemRights -match 'FullControl' -and $_.IdentityReference -notmatch '(?i)\\(SYSTEM|Administrators|Domain Admins|Enterprise Admins)$' -and $_.IdentityReference -notmatch '(?i)^(CREATOR OWNER|NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators)$' } | Select-Object -First 1 if ($w) { $s.CRLShareWriterGroup = "$($w.IdentityReference)" } } } } else { $s.CRLShareConfigured = $false } } catch { } # --- App Proxy connector (local view) --- try { $wapc = Get-Service -Name WAPCSvc -ErrorAction Stop $s.AppProxyConnectorInstalled = $true $s.AppProxyConnectorStatus = "$($wapc.Status)" } catch { $s.AppProxyConnectorInstalled = $false } # --- IIS /CertEnroll/ endpoint (the App Proxy CRL app's backend) --- try { if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) { $s.IISInstalled = [bool](Get-WindowsFeature -Name Web-Server -ErrorAction Stop).Installed } } catch { } if ($s.IISInstalled) { try { Import-Module WebAdministration -ErrorAction Stop $s.CertEnrollVDirPresent = [bool](Get-WebVirtualDirectory -Site 'Default Web Site' -Name 'CertEnroll' -ErrorAction SilentlyContinue) } catch { } } else { $s.CertEnrollVDirPresent = $false } # --- derived readiness gate (menu item 8 checks this) --- $s.IssuanceReady = [bool]( $s.HasExternalCDP -and $s.HasExternalAIAorOCSP -and $s.AppProxyConnectorInstalled -and $s.CertEnrollVDirPresent ) return [pscustomobject]$s } # --------------------------------------------------------------------------- function Show-CAStatus { <# .SYNOPSIS Renders a Get-CAStatus object as the colorized dashboard header. #> param([Parameter(Mandatory)]$Status) $yn = { param($v) if ($null -eq $v) { 'Unknown' } elseif ($v) { 'Yes' } else { 'No' } } $W = 27 # label column width $L = { param($t) ("{0,-$W}" -f $t) } $pad = ' ' * $W # continuation-line indent $roleColor = if ($Status.ADCSRoleInstalled) { 'Green' } elseif ($null -eq $Status.ADCSRoleInstalled) { 'Gray' } else { 'Yellow' } Write-Host ((& $L "AD CS role installed:") + (& $yn $Status.ADCSRoleInstalled)) -ForegroundColor $roleColor if ($Status.CACommonName) { Write-Host ((& $L "CA:") + ("{0} ({1})" -f $Status.CACommonName, $(if ($Status.CAType) { $Status.CAType } else { 'type unknown' }))) if ($Status.ParentCAConfig) { Write-Host ((& $L " Subordinate to:") + $Status.ParentCAConfig) -ForegroundColor Gray } if ($Status.CACertNotAfter) { $cc = if ($Status.CACertDaysLeft -lt 30) { 'Red' } elseif ($Status.CACertDaysLeft -lt 90) { 'Yellow' } else { 'Green' } Write-Host ((& $L " CA cert expires:") + ("{0:yyyy-MM-dd} ({1} days left)" -f $Status.CACertNotAfter, $Status.CACertDaysLeft)) -ForegroundColor $cc } } else { Write-Host ((& $L "CA:") + "not detected (no CA on this box, or certutil -cainfo unavailable)") -ForegroundColor Yellow } $ocspColor = if ($Status.OCSPRoleInstalled -and $Status.OCSPServiceRunning) { 'Green' } elseif ($null -eq $Status.OCSPRoleInstalled) { 'Gray' } else { 'Yellow' } Write-Host ((& $L "OCSP responder:") + ("role {0}, service {1}{2}" -f (& $yn $Status.OCSPRoleInstalled), (& $yn $Status.OCSPServiceRunning), $(if ($Status.OCSPRevocationConfigCount) { " ($($Status.OCSPRevocationConfigCount) revocation config(s))" } else { '' }))) -ForegroundColor $ocspColor if ($null -ne $Status.PublishedTemplateCount) { $present = @($Status.ExpectedTemplateStatus | Where-Object Published).Count $total = @($Status.ExpectedTemplateStatus).Count $tc = if ($present -eq $total -and $total -gt 0) { 'Green' } else { 'Yellow' } Write-Host ((& $L "Custom templates:") + ("{0} of {1} expected published (of {2} total on the CA)" -f $present, $total, $Status.PublishedTemplateCount)) -ForegroundColor $tc foreach ($t in $Status.ExpectedTemplateStatus) { Write-Host (" {0} {1}" -f $(if ($t.Published) { '[x]' } else { '[ ]' }), $t.Name) -ForegroundColor $(if ($t.Published) { 'Gray' } else { 'DarkYellow' }) } } else { Write-Host ((& $L "Custom templates:") + "could not read (certutil -CATemplates did not report success)") -ForegroundColor Gray } Write-Host ((& $L "AIA/CDP -> App Proxy:") + ("CDP external {0}, AIA/OCSP external {1}" -f (& $yn $Status.HasExternalCDP), (& $yn $Status.HasExternalAIAorOCSP))) ` -ForegroundColor $(if ($Status.HasExternalCDP -and $Status.HasExternalAIAorOCSP) { 'Green' } else { 'Yellow' }) if ($Status.CRLShareConfigured) { Write-Host ((& $L "CRL share:") + ("{0}{1}" -f $Status.CRLSharePath, $(if ($Status.CRLShareWriterGroup) { " (writer: $($Status.CRLShareWriterGroup))" } else { '' }))) -ForegroundColor Gray } elseif ($false -eq $Status.CRLShareConfigured) { Write-Host ((& $L "CRL share:") + "none configured (single-tier CA - only needed for sub-CA / segregated proxy)") -ForegroundColor Gray } $aeColor = switch ($Status.AutoEnrollPolicyPresent) { $true { 'Green' } $false { 'Yellow' } default { 'Gray' } } Write-Host ((& $L "Auto-enroll policy:") + (& $yn $Status.AutoEnrollPolicyPresent)) -ForegroundColor $aeColor if ($Status.UmbrellaGroup) { Write-Host ((& $L "Auto-enroll umbrella grp:") + $Status.UmbrellaGroup) -ForegroundColor Gray } $connColor = if ($Status.AppProxyConnectorStatus -eq 'Running') { 'Green' } else { 'Yellow' } Write-Host ((& $L "App Proxy connector:") + $(if ($Status.AppProxyConnectorInstalled) { $Status.AppProxyConnectorStatus } else { 'not installed' })) -ForegroundColor $connColor if ($null -ne $Status.CertEnrollVDirPresent) { $ceColor = if ($Status.CertEnrollVDirPresent) { 'Green' } else { 'Yellow' } Write-Host ((& $L "IIS /CertEnroll/ endpoint:") + $(if ($Status.CertEnrollVDirPresent) { 'present' } else { 'not configured' })) -ForegroundColor $ceColor } Write-Host "" if ($Status.IssuanceReady) { Write-Host ((& $L "Issuance readiness:") + "READY - external revocation path + connector present") -ForegroundColor Green } else { Write-Host ((& $L "Issuance readiness:") + "NOT READY - route AIA/CDP/OCSP through the App Proxy and") -ForegroundColor Yellow Write-Host ($pad + "install the connector BEFORE issuing any certificates") -ForegroundColor Yellow Write-Host ($pad + "(issued certs bake their revocation URLs in permanently).") -ForegroundColor Yellow } foreach ($n in $Status.Notes) { Write-Host " note: $n" -ForegroundColor DarkGray } Write-Host "" } # --------------------------------------------------------------------------- function Get-CAManagementPrereqStatus { <# .SYNOPSIS Which PowerShell modules CA-Manager's own steps call, and whether they're present: ActiveDirectory -> umbrella-group wizard (menu 5) + AD principal resolution GroupPolicy -> GPO reporting in the inventory (menu 14) - GPO CREATION itself moved to the new AD-Manager tool 2026-09-12 (was this dashboard's own menu 10), this module is still wanted here for the inventory's own read-only GPO report section Microsoft.Graph.Authentication -> App Proxy connector + Entra apps (menu 6) #> [pscustomobject]@{ ActiveDirectory = [bool](Get-Module -ListAvailable -Name ActiveDirectory) GroupPolicy = [bool](Get-Module -ListAvailable -Name GroupPolicy) GraphAuth = [bool](Get-Module -ListAvailable -Name Microsoft.Graph.Authentication) } } # --------------------------------------------------------------------------- function Save-CAAnswers { <# .SYNOPSIS Persists the in-memory $CAAnswers object back to CAAnswers.json next to the dashboard, so values a wizard resolves at run time (menu 6's real App Proxy external hostnames) survive to the next session and to menu 8. No-op if $Answers is $null. Routes through Invoke-CAStep. #> param( [Parameter(Mandatory)][string]$Path, $Answers ) if ($null -eq $Answers) { return } Invoke-CAStep -Description "Save resolved answers to $Path" ` -Commands @("`$CAAnswers | ConvertTo-Json -Depth 6 | Set-Content '$Path'") ` -Action { $json = $Answers | ConvertTo-Json -Depth 6 $json = ($json -replace "`r`n", "`n") -replace "`n", "`r`n" Set-Content -Path $Path -Value $json -Encoding UTF8 } -ContinueOnError | Out-Null } # --------------------------------------------------------------------------- function Install-CAGraphModule { <# .SYNOPSIS Installs Microsoft.Graph.Authentication machine-wide (-Scope AllUsers - CA-Manager always runs elevated, and the AllUsers module dir survives the PSModulePath rewrite the Entra connector installer does; CurrentUser does not). Bootstraps the two things a fresh Windows Server is usually missing first: TLS 1.2 for the PowerShell Gallery session and the NuGet package provider. Verifies the module is visible afterwards; throws loudly if not. .NOTES The "is PowerShellGet loadable" probe runs UNCONDITIONALLY, even in DRY RUN - it's a read-only Import-Module of an already-on-disk module, not a system mutation. Previously it lived inside an Invoke-CAStep -Action block, which SKIPS -Action entirely in dry-run - so the very next check (Get-Command Install-Module) always came back empty on a DRY RUN pass regardless of the box's actual state, and printed the "PowerShellGet is not present" fallback even when the module and PSModulePath were both completely fine (found live 2026-09-10 on a client's CA server - Server 2022, module present on disk, PSModulePath correct, a real Import-Module succeeded by hand; only the DRY RUN pass inside CA-Manager reported it missing). Only the actually-mutating calls (Install-PackageProvider / Set-PSRepository / Install-Module) stay behind Invoke-CAStep. #> if (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication) { Write-Host " Microsoft.Graph.Authentication already installed." -ForegroundColor Green return } # Re-assert PSModulePath right before the probe below, not just once at CA-Manager startup - an # installer run earlier in this same session (the Entra connector, TameMyCerts, a .NET runtime # install) can leave it malformed, and this rebuilds it from Test-Path-validated directories. if (Get-Command Repair-CAModulePath -ErrorAction SilentlyContinue) { Repair-CAModulePath } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $importErrors = New-Object System.Collections.Generic.List[string] foreach ($m in 'PackageManagement', 'PowerShellGet') { try { Import-Module $m -ErrorAction Stop } catch { $importErrors.Add("$m -> $($_.Exception.Message)") } } Invoke-CAStep -Description "Bootstrap the NuGet provider + trust the PSGallery repository" ` -Commands @('Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force', 'Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') ` -Action { # PackageManagement may be absent entirely (stripped image); let Install-Module deal with # the NuGet provider itself in that case rather than aborting here. if (Get-Command Install-PackageProvider -ErrorAction SilentlyContinue) { try { if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null } } catch { Write-Host " (NuGet provider bootstrap skipped: $($_.Exception.Message))" -ForegroundColor DarkYellow } } else { Write-Host " (PackageManagement not available - Install-Module will bootstrap NuGet on its own)" -ForegroundColor DarkYellow } try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch { } } -ContinueOnError | Out-Null if (-not (Get-Command Install-Module -ErrorAction SilentlyContinue)) { Write-Host "" Write-Host " PowerShellGet is not present / not loadable on this box - can't Install-Module anything." -ForegroundColor Yellow foreach ($e in $importErrors) { Write-Host " $e" -ForegroundColor Yellow } Write-Host (" LanguageMode : {0}" -f $ExecutionContext.SessionState.LanguageMode) -ForegroundColor Gray Write-Host (" PSModuleAutoLoadingPreference : {0}" -f $(if ($null -ne $PSModuleAutoLoadingPreference) { $PSModuleAutoLoadingPreference } else { '(unset - default All)' })) -ForegroundColor Gray Write-Host (" PSModulePath : {0}" -f $env:PSModulePath) -ForegroundColor Gray Write-Host " Get Microsoft.Graph.Authentication on manually before menu 6:" -ForegroundColor Yellow Write-Host " 1. On a box that HAS PowerShellGet:" -ForegroundColor Gray Write-Host " Save-Module Microsoft.Graph.Authentication -Path C:\temp\gmods" -ForegroundColor Gray Write-Host " 2. Copy C:\temp\gmods\Microsoft.Graph.Authentication to this box's" -ForegroundColor Gray Write-Host " C:\Program Files\WindowsPowerShell\Modules\" -ForegroundColor Gray Write-Host " 3. Relaunch CA-Manager. (Menu 6 only.)" -ForegroundColor Gray return } Invoke-CAStep -Description "Install Microsoft.Graph.Authentication (AllUsers)" ` -Commands @('Install-Module Microsoft.Graph.Authentication -Scope AllUsers -Force -AllowClobber') ` -Action { Install-Module Microsoft.Graph.Authentication -Scope AllUsers -Force -AllowClobber -ErrorAction Stop | Out-String } | Out-Null if (-not (Get-CADryRun) -and -not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { throw "Install-Module reported no error but Microsoft.Graph.Authentication is still not visible. Check internet / proxy, then run 'Install-Module Microsoft.Graph.Authentication -Scope AllUsers -Force -Verbose' by hand." } Write-Host " Microsoft.Graph.Authentication installed. Re-run menu 6." -ForegroundColor Green } # --------------------------------------------------------------------------- function Install-CAManagementPrereqs { <# .SYNOPSIS Installs the RSAT tooling CA-Manager needs. On Windows Server -> Install-WindowsFeature (RSAT-AD-PowerShell + GPMC, optionally RSAT-ADCS-Mgmt / RSAT-Online-Responder); on a client OS -> Add-WindowsCapability -Online. Everything routes through Invoke-CAStep so DRY RUN just prints. Re-launch CA-Manager afterwards so the new modules import. #> param( [switch]$IncludeAdcsTools, [switch]$IncludeOcspTools ) $isServer = $true try { $isServer = ((Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).ProductType -ne 1) } catch { } if ($isServer -and (Get-Command Install-WindowsFeature -ErrorAction SilentlyContinue)) { $features = [System.Collections.Generic.List[string]]@('RSAT-AD-PowerShell', 'GPMC') if ($IncludeAdcsTools) { $features.Add('RSAT-ADCS-Mgmt') } if ($IncludeOcspTools) { $features.Add('RSAT-Online-Responder') } $need = @($features | Where-Object { -not (Get-WindowsFeature -Name $_ -ErrorAction SilentlyContinue).Installed }) if (-not $need.Count) { Write-Host " Already installed: $($features -join ', ')" -ForegroundColor Green return } Invoke-CAStep -Description "Install management features: $($need -join ', ')" ` -Commands @("Install-WindowsFeature $($need -join ',') -IncludeManagementTools") ` -Action { Install-WindowsFeature -Name $need -IncludeManagementTools | Out-String } | Out-Null } else { $caps = [System.Collections.Generic.List[string]]@( 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0' 'Rsat.GroupPolicy.Management.Tools~~~~0.0.1.0' ) if ($IncludeAdcsTools -or $IncludeOcspTools) { $caps.Add('Rsat.CertificateServices.Tools~~~~0.0.1.0') } $need = @($caps | Where-Object { (Get-WindowsCapability -Online -Name $_ -ErrorAction SilentlyContinue).State -ne 'Installed' }) if (-not $need.Count) { Write-Host " All RSAT capabilities already installed." -ForegroundColor Green return } foreach ($c in $need) { $cap = $c Invoke-CAStep -Description "Add RSAT capability $cap" ` -Commands @("Add-WindowsCapability -Online -Name $cap") ` -Action { Add-WindowsCapability -Online -Name $cap | Out-String } | Out-Null } } Write-Host " Re-launch CA-Manager so the new modules load." -ForegroundColor Cyan } # --------------------------------------------------------------------------- function Invoke-CAManagerRelaunch { <# .SYNOPSIS Starts a fresh, elevated CA-Manager.ps1 process (so a just-installed RSAT module / a refreshed PSModulePath actually imports) and ends the current session. 2026-09-11: added after the maintainer asked for a "relaunch CA-Manager" option instead of having to close the window and double-click the script again by hand. .DESCRIPTION Mirrors CA-Manager.ps1's own top-of-file self-elevate relaunch exactly (same args, same Start-Process -Verb RunAs), so a tech gets a normal fresh session, not some other code path. Deliberately takes -ScriptPath/-ShimPath/-CAConfigName as EXPLICIT params rather than reading $PSCommandPath/$ShimPath itself - this function is dot-sourced into CA-Manager.ps1 from a module file, and a function's automatic variables do not reliably reflect the calling top-level script's own identity (same reasoning ShimEngine.ps1 documents for its own -ShimPath convention). Callers in CA-Manager.ps1 itself have all three directly in scope. #> param( [Parameter(Mandatory)][string]$ScriptPath, [string]$ShimPath, [string]$CAConfigName ) # NSP.PKI: -ScriptPath is the module manifest - relaunch Start-NSPPkiManager in a new elevated # window, then end this dashboard by returning to its caller (Start-NSPPkiManager stops on the # NSP.PKI:Relaunched signal) rather than exiting the host, which may be the tech's own console. if ($ScriptPath -like '*.psd1') { $command = "Import-Module '$($ScriptPath.Replace("'", "''"))'; Start-NSPPkiManager" if (-not [string]::IsNullOrWhiteSpace($CAConfigName)) { $command += " -CAConfigName '$($CAConfigName.Replace("'", "''"))'" } $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) Write-Host " Relaunching PKI Manager..." -ForegroundColor Cyan Start-Process powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-EncodedCommand', $encoded) -Verb RunAs throw (New-Object System.OperationCanceledException 'NSP.PKI:Relaunched') } $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$ScriptPath`"") if (-not [string]::IsNullOrWhiteSpace($ShimPath)) { $relaunchArgs += @('-ShimPath', "`"$ShimPath`"") } if (-not [string]::IsNullOrWhiteSpace($CAConfigName)) { $relaunchArgs += @('-CAConfigName', "`"$CAConfigName`"") } Write-Host " Relaunching CA-Manager..." -ForegroundColor Cyan Start-Process powershell.exe -ArgumentList $relaunchArgs -Verb RunAs exit 0 } # --------------------------------------------------------------------------- function Invoke-CAManagerUpdate { <# .SYNOPSIS Re-runs the STAGED SHIM (not just this dashboard) so a tech gets the latest CAManager.zip without leaving the dashboard, manually re-downloading, or hand-copying files themselves. 2026-09-15, per the maintainer - directly grew out of a same-day live debugging session where he had to be told, each time, to go re-copy specific fixed files onto his box by hand. .DESCRIPTION -Relaunch (menu R / Invoke-CAManagerRelaunch) only re-runs THIS SAME already-extracted CA-Manager.ps1 - it fixes a "just installed a module, need a fresh process" problem, but it can never pick up a code fix, because it never touches CAManager.zip at all. The shim (CA-Manager-Shim.ps1) is the ONLY thing that re-downloads the zip, extracts -Force over the current staging folder, reconciles CAAnswers.json, and self-updates its own template - so getting a genuine update means re-running THAT, not this dashboard. Only meaningful when this session was actually launched via a staged shim - $ShimPath is empty for a standalone/dev run (e.g. straight out of the repo), where there's no "latest zip" concept to re-fetch at all; the caller must check for that and tell the tech plainly rather than silently doing nothing. No -Verb RunAs on the relaunch - this process is already elevated, and a child process of an elevated process inherits that elevation on Windows (same reasoning CA-Manager-Shim.ps1's own STEP 1.5 self-update documents for its own relaunch). #> param( [Parameter(Mandatory)][string]$ShimPath ) if ([string]::IsNullOrWhiteSpace($ShimPath) -or -not (Test-Path $ShimPath)) { throw "No staged shim path known for this session (`$ShimPath is blank, or '$ShimPath' no longer exists) - this looks like a standalone/dev run, not one launched via the staged CA-Manager.ps1 shim. Re-run through the shim to get updates." } Write-Host " Re-running the shim to fetch the latest CAManager.zip..." -ForegroundColor Cyan Start-Process powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$ShimPath`"") exit 0 } # --------------------------------------------------------------------------- function Resolve-CAADPrincipalInteractive { <# .SYNOPSIS Resolves a typed name/wildcard search term to a real AD group OR computer account, returning a "DOMAIN\Name" string ready to feed straight into Grant-CATemplateEnrollment / Set-CATemplateAutoEnroll's -PrincipalName (a resolved computer account's Name already carries its trailing $, matching what NTAccount(...).Translate(...) expects for a machine principal). 2026-09-11, per the maintainer: the OCSP-responder-host prompt (menu 4) used to just take whatever was typed verbatim, with no way to tell "OCSP-Host" apart from "OCSP-Host01" until the ACL grant itself failed later with an opaque "could not resolve to a security identifier" error. .DESCRIPTION Same 0/1/many-hit search UX already proven twice in this codebase (Invoke-CAMenuUmbrella's own inline nested-group picker right here in CATemplates.ps1; Resolve-NPSGroupInteractive in NPS-Manager) - exact name first (tried against BOTH Get-ADGroup and Get-ADComputer), falls back to a '*term*' wildcard search across both object types, auto-picks on exactly one combined hit, offers a numbered pick-list on multiple, and - unlike the NPS/umbrella pickers, which can offer to CREATE a missing group - just keeps the typed term as-is, unresolved, on zero hits or an explicit skip. An OCSP responder host is virtually always an EXISTING computer or group; offering to create one here would be the wrong default action for what this field is actually for. Tolerant of how the tech actually types it - a leading "DOMAIN\" prefix or a trailing "$" (the exact style the prompt's own example shows, e.g. "CONTOSO\CONTOSO-CA$") is stripped before searching, since Get-ADComputer's own Name property never carries the trailing $. #> param([Parameter(Mandatory)][string]$SearchTerm) if (-not (Get-Command Get-ADGroup -ErrorAction SilentlyContinue) -or -not (Get-Command Get-ADComputer -ErrorAction SilentlyContinue)) { Write-Host " ActiveDirectory module not available - saving '$SearchTerm' as typed, unresolved. Run menu 1 (RSAT) to search AD next time." -ForegroundColor Yellow return $SearchTerm } $domain = $env:USERDOMAIN $term = $SearchTerm.Trim().TrimEnd('$') if ($term -match '^[^\\]+\\(.+)$') { $term = $Matches[1] } if ([string]::IsNullOrWhiteSpace($term)) { return $SearchTerm } $escaped = $term.Replace("'", "''") # Exact match first, against BOTH object types - only trusted when just ONE type matches exactly # (a same-named group AND computer both existing is rare but real; fall through to the wildcard # search + pick-list below rather than silently guessing which one was meant). $exactGroup = @(Get-ADGroup -Filter "Name -eq '$escaped'" -ErrorAction SilentlyContinue) $exactComputer = @(Get-ADComputer -Filter "Name -eq '$escaped'" -ErrorAction SilentlyContinue) if ($exactGroup.Count -eq 1 -and $exactComputer.Count -eq 0) { Write-Host " Exact match: group '$($exactGroup[0].Name)'" -ForegroundColor Green return "$domain\$($exactGroup[0].Name)" } if ($exactComputer.Count -eq 1 -and $exactGroup.Count -eq 0) { Write-Host " Exact match: computer '$($exactComputer[0].Name)`$'" -ForegroundColor Green return "$domain\$($exactComputer[0].SamAccountName)" } Write-Host " Searching AD for groups/computers matching '*$term*'..." -ForegroundColor Cyan $groupHits = @(Get-ADGroup -Filter "Name -like '*$escaped*'" -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Kind = 'Group'; Display = $_.Name; Value = "$domain\$($_.Name)" } }) $computerHits = @(Get-ADComputer -Filter "Name -like '*$escaped*'" -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Kind = 'Computer'; Display = "$($_.Name)`$"; Value = "$domain\$($_.SamAccountName)" } }) $hits = @($groupHits + $computerHits | Sort-Object Kind, Display) if ($hits.Count -eq 0) { Write-Host " No AD groups or computers found matching '*$term*' - saving '$SearchTerm' as typed, unresolved." -ForegroundColor Yellow return $SearchTerm } if ($hits.Count -eq 1) { Write-Host " Matched exactly one: [$($hits[0].Kind)] $($hits[0].Display)" -ForegroundColor Green return $hits[0].Value } Write-Host " '$term' matched $($hits.Count):" -ForegroundColor Cyan for ($i = 0; $i -lt $hits.Count; $i++) { Write-Host (" {0}. [{1}] {2}" -f ($i + 1), $hits[$i].Kind, $hits[$i].Display) } $sel = Read-Host " Select a number (blank to keep '$SearchTerm' as typed, unresolved)" $idx = ($sel -as [int]) - 1 if ($idx -ge 0 -and $idx -lt $hits.Count) { Write-Host " Resolved -> $($hits[$idx].Value)" -ForegroundColor Green return $hits[$idx].Value } Write-Host " Kept '$SearchTerm' as typed, unresolved." -ForegroundColor Yellow return $SearchTerm } |