Private/CAInteractive.ps1
|
<#
.SYNOPSIS Shared Read-Host-driven wizard helpers for CA Manager. Requires Modules\CACore.ps1 to already be dot-sourced (Write-CAHeader etc.). .DESCRIPTION Phase 1 content is intentionally minimal - just the small prompt helpers the later-phase wizards (template creation, umbrella-group nesting, GPO push, App Proxy, test-cert suite) will all share. The wizards themselves land in their own phases. #> # --------------------------------------------------------------------------- function Read-CAConfirm { <# .SYNOPSIS Y/N confirmation prompt. Returns [bool]. Blank input -> $DefaultYes. #> param( [Parameter(Mandatory)][string]$Prompt, [switch]$DefaultYes ) $suffix = if ($DefaultYes) { ' [Y/n]' } else { ' [y/N]' } while ($true) { $r = Read-Host ($Prompt + $suffix) if ([string]::IsNullOrWhiteSpace($r)) { return [bool]$DefaultYes } if ($r -match '^(?i)y(es)?$') { return $true } if ($r -match '^(?i)n(o)?$') { return $false } Write-Host " Please answer y or n." -ForegroundColor Yellow } } # --------------------------------------------------------------------------- # Back-navigation (2026-09-10, Part A item 3) - reuses CLIBuilder's own convention (a literal 'B' # input steps back one prompt) rather than inventing a second idiom. $script:CABackSignal is a # private sentinel string, astronomically unlikely to collide with real user input (a path, a name, # a hostname); Test-CABackSignal is the one place that comparison lives, so call sites never need to # know the sentinel's literal value. Only offered where a wizard is a genuinely undo-able SEQUENCE OF # PROMPTS with no mutating action interleaved between them - see the .NOTES on each rollout site for # why a given wizard does or doesn't offer it end-to-end. $script:CABackSignal = '<<CA_BACK>>' function Test-CABackSignal { <# .SYNOPSIS True if $Value is the "go back one prompt" sentinel a back-aware prompt helper returned. #> param($Value) return ($Value -is [string]) -and ($Value -ceq $script:CABackSignal) } # --------------------------------------------------------------------------- function Read-CANonEmpty { <# .SYNOPSIS Prompts until a non-blank value is entered (or, if -CurrentValue is given, blank keeps it). .PARAMETER AllowBack When set, a literal 'B'/'Back' response returns $script:CABackSignal (see Test-CABackSignal) instead of being treated as the value - pass this only when the caller is actually prepared to step back to a previous prompt (there IS one), same as CLIBuilder's $CanGoBack convention. #> param( [Parameter(Mandatory)][string]$Prompt, [string]$CurrentValue, [switch]$AllowBack ) $hint = if ($PSBoundParameters.ContainsKey('CurrentValue') -and -not [string]::IsNullOrWhiteSpace($CurrentValue)) { " (blank to keep '$CurrentValue')" } else { '' } $backHint = if ($AllowBack) { ' [B = back]' } else { '' } while ($true) { $r = Read-Host ($Prompt + $hint + $backHint) if ($AllowBack -and $r -match '^(?i)b(ack)?$') { return $script:CABackSignal } if ([string]::IsNullOrWhiteSpace($r)) { if (-not [string]::IsNullOrWhiteSpace($CurrentValue)) { return $CurrentValue } Write-Host " A value is required." -ForegroundColor Yellow continue } return $r.Trim() } } # --------------------------------------------------------------------------- function Read-CAOptional { <# .SYNOPSIS A single Read-Host for a field that's allowed to be blank (no "value is required" loop) - the back-aware equivalent of a bare Read-Host call, for wizard prompts that accept an empty answer (skip / default) rather than requiring one. #> param( [Parameter(Mandatory)][string]$Prompt, [switch]$AllowBack ) $backHint = if ($AllowBack) { ' [B = back]' } else { '' } $r = Read-Host ($Prompt + $backHint) if ($AllowBack -and $r -match '^(?i)b(ack)?$') { return $script:CABackSignal } return $r } # --------------------------------------------------------------------------- function Invoke-CAWizardSteps { <# .SYNOPSIS Drives an ordered array of prompt steps with back-navigation, mirroring CLIBuilder's $EntryIdx loop (Interactive - IPSec_IKEV2- CLI Builder V2.ps1): each step is a scriptblock taking one [bool]$CanGoBack argument (true once there's a prior step to return to) and returning either a value or $script:CABackSignal (via a back-aware prompt helper above). Stepping back always re-runs the step you land on from scratch - same "recompute fresh" convention CLIBuilder itself uses, not a rewind to a remembered prior answer. .PARAMETER Steps Array of @{ Name = '...'; Run = { param($CanGoBack) ... } } entries, in order. .OUTPUTS A hashtable keyed by each step's Name, holding what its Run scriptblock returned (skipping back-signal iterations - only the FINAL, accepted value per step lands in the result). #> param([Parameter(Mandatory)][object[]]$Steps) $result = @{} $i = 0 while ($i -lt $Steps.Count) { $step = $Steps[$i] $r = & $step.Run ($i -gt 0) # Clamp rather than decrement past 0 - a bare "$i--" here would take $i to -1, and PowerShell # arrays support negative indexing, so $Steps[-1] would silently jump to the LAST step instead # of refusing to go back further. A step should never return the back signal when its own # $CanGoBack was false, but this is the defense-in-depth net if one does anyway. if (Test-CABackSignal $r) { $i = [Math]::Max(0, $i - 1); continue } $result[$step.Name] = $r $i++ } return $result } # --------------------------------------------------------------------------- function Test-SecureStringMatch { <# .SYNOPSIS Returns $true if two SecureStrings hold the same plaintext. Used to confirm a "type it twice" password prompt without ever printing the value. #> param( [Parameter(Mandatory)][securestring]$A, [Parameter(Mandatory)][securestring]$B ) $pa = [System.Net.NetworkCredential]::new('x', $A).Password $pb = [System.Net.NetworkCredential]::new('x', $B).Password return ($pa -ceq $pb) -and -not [string]::IsNullOrEmpty($pa) } # --------------------------------------------------------------------------- function Get-CAAnswerOrPrompt { <# .SYNOPSIS Returns $CAAnswers.<Field> if set, otherwise prompts for it (and, if -Remember, writes it back onto the in-memory $CAAnswers object so later steps in the same session reuse it). .PARAMETER Default Optional. When given, blank Enter at the prompt accepts this value instead of re-prompting - pass it whenever -Prompt itself displays a bracketed default (e.g. "... [C:\PolicyFiles]"), or blank Enter will loop with "A value is required." even though the prompt implied one exists (found live 2026-09-10, menu S's policy-directory prompt). .PARAMETER AllowBack Only meaningful when this call actually prompts (a remembered/already-set $cur short-circuits before any prompt happens, so there's nothing to back out of). Pass the caller's own "is there a previous step" condition - typically an Invoke-CAWizardSteps step's $CanGoBack argument. When the operator types B/Back, returns $script:CABackSignal (Test-CABackSignal) instead of prompting further or writing anything back - the caller decides what "back" means. #> param( [Parameter(Mandatory)]$CAAnswers, [Parameter(Mandatory)][string]$Field, [Parameter(Mandatory)][string]$Prompt, [string]$Default, [switch]$Remember, [switch]$AllowBack ) $cur = if ($CAAnswers -and $CAAnswers.PSObject.Properties[$Field]) { $CAAnswers.$Field } else { $null } if (-not [string]::IsNullOrWhiteSpace($cur)) { return $cur } $val = if ($PSBoundParameters.ContainsKey('Default')) { Read-CANonEmpty -Prompt $Prompt -CurrentValue $Default -AllowBack:$AllowBack } else { Read-CANonEmpty -Prompt $Prompt -AllowBack:$AllowBack } if (Test-CABackSignal $val) { return $val } if ($Remember -and $CAAnswers) { if ($CAAnswers.PSObject.Properties[$Field]) { $CAAnswers.$Field = $val } else { $CAAnswers | Add-Member -NotePropertyName $Field -NotePropertyValue $val -Force } } return $val } # --------------------------------------------------------------------------- function Get-CAAnswerOrPromptOptional { <# .SYNOPSIS Like Get-CAAnswerOrPrompt, but for a field where BLANK is a legitimate answer (e.g. "skip this ACL for now") - never forces a non-empty retry loop. A non-blank answer is always remembered onto $CAAnswers (persistence-audit fix, 2026-09-10: several such fields used to be bare, unremembered Read-Host calls, re-asked every run even after a real answer was already given). A BLANK answer is deliberately NOT remembered - it keeps asking again next time, preserving the "skip for now" promise these prompts make rather than silently locking in "never configure this" forever the first time someone leaves it blank. .PARAMETER Resolve 2026-09-11, per the maintainer: optional scriptblock, given the raw typed (non-blank) value, returning the FINAL value to actually store - e.g. Resolve-CAADPrincipalInteractive turning a typed search term into a real, disambiguated "DOMAIN\Name". Only runs on a NON-blank typed answer (blank/skip never invokes it - "skip for now" stays exactly that). Only runs on a FRESH prompt, never on the already-answered fast path above - an already-resolved, already-saved value is never re-searched on a later visit to the same menu. #> param( [Parameter(Mandatory)]$CAAnswers, [Parameter(Mandatory)][string]$Field, [Parameter(Mandatory)][string]$Prompt, [switch]$AllowBack, [scriptblock]$Resolve ) $cur = if ($CAAnswers -and $CAAnswers.PSObject.Properties[$Field]) { $CAAnswers.$Field } else { $null } if (-not [string]::IsNullOrWhiteSpace($cur)) { return $cur } $val = Read-CAOptional -Prompt $Prompt -AllowBack:$AllowBack if (Test-CABackSignal $val) { return $val } if (-not [string]::IsNullOrWhiteSpace($val) -and $Resolve) { $val = & $Resolve $val } if (-not [string]::IsNullOrWhiteSpace($val) -and $CAAnswers) { if ($CAAnswers.PSObject.Properties[$Field]) { $CAAnswers.$Field = $val } else { $CAAnswers | Add-Member -NotePropertyName $Field -NotePropertyValue $val -Force } } return $val } # --- dashboard menu-action wrappers (each gathers the CA_* values it needs, then calls the engine # in Modules\CA*.ps1; everything mutating goes through Invoke-CAStep so dry-run is honoured) --- function Invoke-CAMenuPrereqs { <# .PARAMETER ScriptPath / ShimPath / CAConfigName 2026-09-11: passed through only so this menu can offer an immediate "relaunch CA-Manager" once it's actually installed something - see Invoke-CAManagerRelaunch (CACore.ps1). Optional; blank just means the relaunch offer is skipped (falls back to the old "re-launch by hand" message), never a hard failure. #> param( [string]$ScriptPath, [string]$ShimPath, [string]$CAConfigName ) Write-CAHeader "Install RSAT / management modules" $st = Get-CAManagementPrereqStatus Write-Host (" ActiveDirectory module : {0}" -f $(if ($st.ActiveDirectory) { 'present' } else { 'MISSING - needed for menu 5 (umbrella group)' })) -ForegroundColor $(if ($st.ActiveDirectory) { 'Green' } else { 'Yellow' }) Write-Host (" GroupPolicy module : {0}" -f $(if ($st.GroupPolicy) { 'present' } else { 'MISSING - needed for menu 14 (inventory GPO report)' })) -ForegroundColor $(if ($st.GroupPolicy) { 'Green' } else { 'Yellow' }) Write-Host (" Microsoft.Graph.Authentication : {0}" -f $(if ($st.GraphAuth) { 'present' } else { 'MISSING - needed for menu 6 (App Proxy)' })) -ForegroundColor $(if ($st.GraphAuth) { 'Green' } else { 'Yellow' }) Write-Host "" # 2026-09-11, per the maintainer's Y/N-defaults review: every prompt below now defaults to Yes - said # explicitly here so a tech blank-Entering through them knows that's what's about to happen, # rather than assuming Enter means "skip". Write-Host " Everything below defaults to Yes - answer N for anything you don't want on this box." -ForegroundColor Cyan Write-Host "" $adcs = Read-CAConfirm -Prompt "Also install the AD CS management tools (certtmpl / pkiview)?" -DefaultYes $ocsp = Read-CAConfirm -Prompt "Also install the Online Responder management tools?" -DefaultYes $didInstall = $false # Gather every "what to install" choice BEFORE the one "Proceed?" that actually triggers # everything (2026-09-11, per the maintainer - Graph Auth used to be asked AFTER the RSAT install had # already run, so a tech had to answer a second, separate "proceed" mid-flow instead of reviewing # and confirming the whole plan in one go). $installGraph = -not $st.GraphAuth -and (Read-CAConfirm -Prompt "Install Microsoft.Graph.Authentication (machine-wide) now?" -DefaultYes) if (Read-CAConfirm -Prompt "`nProceed?" -DefaultYes) { Install-CAManagementPrereqs -IncludeAdcsTools:$adcs -IncludeOcspTools:$ocsp $didInstall = $true } if ($installGraph) { try { Install-CAGraphModule; $didInstall = $true } catch { Write-Host " $($_.Exception.Message)" -ForegroundColor Red } } # Offer to relaunch right here instead of just telling the tech to do it by hand - only makes # sense if we actually attempted a real (non-dry-run) install, and only if the caller gave us # enough to relaunch with (ScriptPath). Also reachable any time from the dashboard's own 'R' key. if ($didInstall -and -not (Get-CADryRun) -and -not [string]::IsNullOrWhiteSpace($ScriptPath) ` -and (Get-Command Invoke-CAManagerRelaunch -ErrorAction SilentlyContinue) ` -and (Read-CAConfirm -Prompt "`nRelaunch CA-Manager now so the new modules load?" -DefaultYes)) { Invoke-CAManagerRelaunch -ScriptPath $ScriptPath -ShimPath $ShimPath -CAConfigName $CAConfigName return # unreached in practice - Invoke-CAManagerRelaunch exits the process - but keeps this function's own flow honest } Read-Host "`nPress Enter to return to the menu" | Out-Null } function Invoke-CAMenuInstall { param($CAAnswers) Write-CAHeader "Install / configure the CA" if ($CAAnswers -and ($CAAnswers.CA_IsSubordinate -match '^(?i)y')) { Write-Host "This client is flagged CA_IsSubordinate = Yes. The subordinate install path" -ForegroundColor Yellow Write-Host "(CSR -> submit to the online root -> install chain) is not built yet - do it by hand," -ForegroundColor Yellow Write-Host "then use the other menu items for templates / OCSP / GPO / App Proxy." -ForegroundColor Yellow Read-Host "Press Enter to return to the menu" | Out-Null return } # The menu-1 step, up front: menu 2 needs the AD + ADCS management modules (RSAT-AD-PowerShell / GPMC / # RSAT-ADCS-Mgmt). Idempotent + dry-run aware; a fresh install here still needs a relaunch (and # usually a reboot) before the modules import - Invoke-CAInstall's guard catches that. if (Get-Command Install-CAManagementPrereqs -ErrorAction SilentlyContinue) { Write-Host " Checking management prerequisites (RSAT AD / GroupPolicy / ADCS tools)..." -ForegroundColor DarkGray Install-CAManagementPrereqs -IncludeAdcsTools Write-Host "" } $cn = Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_CommonName' -Prompt "Issuing CA common name" -Remember $yrs = [int](Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_ValidityYears' -Prompt "CA cert lifetime (years)" -Remember) if ($yrs -lt 10) { Write-Host "" Write-Host " CA cert lifetime is set to $yrs year(s). A single-tier CA is its own root -" -ForegroundColor Yellow Write-Host " renewing it means re-establishing trust everywhere; 10-15 is the norm." -ForegroundColor Yellow $ans = Read-Host " New value in years, or Enter to keep $yrs" if ($ans -as [int]) { $yrs = [int]$ans if ($CAAnswers) { $CAAnswers | Add-Member -NotePropertyName CA_ValidityYears -NotePropertyValue "$yrs" -Force } } } Show-CAInstallPlan -CAAnswers $CAAnswers if (Read-CAConfirm -Prompt "`nProceed?" ) { Invoke-CAInstall -CACommonName $cn -ValidityYears $yrs ` -CrlPeriodDays $([int](Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_CrlPeriodDays' -Prompt "CRL period (days)" -Remember)) } Read-Host "`nPress Enter to return to the menu" | Out-Null } function Invoke-CAMenuTemplates { param($CAAnswers) Write-CAHeader "Create / update certificate templates" $spec = Get-CATemplateSpecForAnswers -CAAnswers $CAAnswers # Persistence-audit fix (2026-09-10): both used to be bare Read-Host calls, re-asked on every # visit to this menu even after a real answer was already typed once. CA_OcspResponderHost is # also the exact field the enrollment gate (menu 15, Resolve-CAAutoEnrollGatePrincipal) reads for # the SAME principal - sharing the field means the tech is never asked for this host twice across # two different menus, and the gate always resolves to whoever actually got granted Enroll here. # 2026-09-11, per the maintainer: neither is taken verbatim any more - a typed name/wildcard now runs # through an interactive AD group/computer search-and-pick (Resolve-CAADPrincipalInteractive, # CACore.ps1) before being saved, same 0/1/many-hit UX already proven elsewhere in this codebase # (the umbrella nested-group picker just below, NPS-Manager's Resolve-NPSGroupInteractive) - # catches a typo/wrong-suffix mismatch here instead of at the ACL grant, where it used to fail # with an opaque "could not resolve to a security identifier" error. Built for the OCSP-host field # first (an explicit ask); reused here for the umbrella group too - same field shape (a name that # has to resolve to a real AD principal), no reason for one to get the search and not the other. # # 2026-09-15, per the maintainer: asked FIRST now, before the (numbered) plan even renders - it's a quick, # usually-already-answered question that read oddly buried after a wall of per-template detail. $ocspHost = Get-CAAnswerOrPromptOptional -CAAnswers $CAAnswers -Field 'CA_OcspResponderHost' ` -Prompt "OCSP responder host / group name (or partial - wildcard-searched) for the OCSPResponseSigning template ACL (e.g. $($env:COMPUTERNAME)) - blank to skip" ` -Resolve { param($term) Resolve-CAADPrincipalInteractive -SearchTerm $term } # 2026-09-11, per the maintainer ("do we really need IKEv2_MasterGroup at this point? TameMyCerts... it's # just muddying the waters"): confirmed by reading the template-spec code - EnrollPrincipals only # ever contains 'Umbrella' on the single SHARED Auto template spec (Get-CAVpnTemplateSpec), which # Get-CATemplateSpecForAnswers drops entirely in favor of per-group 'GroupSpecific' templates the # moment TameMyCerts + RadiusGroupPairs are both in play - the now-recommended path. So the umbrella # group genuinely grants nothing in that mode. Rather than removing the field/prompt outright (a # non-TameMyCerts client, or a TameMyCerts one with no RadiusGroupPairs yet, still needs it - see # CATameMyCerts.ps1's own single-policy fallback), skip ASKING for it whenever nothing $spec is # about to create would actually use it - computed generically from $spec itself, not a hardcoded # CA_SubjectStampMode check, so this stays correct if the per-group-vs-shared logic changes later. $umbrellaNeeded = [bool]($spec | Where-Object { $_.EnrollPrincipals -contains 'Umbrella' -or $_.AutoEnrollPrincipals -contains 'Umbrella' }) $umbrella = if ($umbrellaNeeded) { Get-CAAnswerOrPromptOptional -CAAnswers $CAAnswers -Field 'CA_AutoEnrollGroup' ` -Prompt "`nAuto-enrollment umbrella group name (or partial - wildcard-searched) - blank to skip its ACLs for now" ` -Resolve { param($term) Resolve-CAADPrincipalInteractive -SearchTerm $term } } else { if ($CAAnswers -and $CAAnswers.PSObject.Properties['CA_AutoEnrollGroup'] -and $CAAnswers.CA_AutoEnrollGroup) { Write-Host "`n (skipping the umbrella-group prompt - none of the templates about to be created/updated use it; TameMyCerts's per-group templates grant Enroll directly to each pair's own AD group instead.)" -ForegroundColor DarkGray } $null } # 2026-09-15, per the maintainer: menu 4 used to show the plan then confirm ALL-OR-NOTHING # ("create/publish the N template(s) now?"). A TameMyCerts client with many RadiusGroupPairs # (one real client hit 11 templates once RadiusGroupPairs actually made it through staging - see that same # day's staging fix) usually only needs to (re)create ONE or two new/changed entries on a given # visit, not batch-touch every template every time - so the tech picks specific numbers instead # of an unconditional "all of them" (re-selecting an already-complete template is still harmless, # New-CAVpnTemplate skips it - this is purely better UX, not a new safety gate). # # 2026-09-15, same-day follow-up: the numbered FULL detail plan (every field/flag per template) was # too much to scan through just to pick numbers - terse by default now (just "[N] DisplayName"), # with 'D' to show the same full Show-CATemplatePlan detail on demand, then re-prompt. Write-Host "" Write-Host " Templates to create/update:" -ForegroundColor Cyan for ($__pi = 0; $__pi -lt $spec.Count; $__pi++) { Write-Host (" [{0}] {1}" -f ($__pi + 1), $spec[$__pi].DisplayName) -ForegroundColor Gray } $pickRaw = $null while ($true) { Write-Host "" Write-Host " Which template number(s) to create/update now?" -ForegroundColor Cyan Write-Host " Comma-separated numbers, 'A' for all $($spec.Count), 'D' for full detail, or blank to cancel." -ForegroundColor Gray $pickRaw = Read-Host " Select" if ($pickRaw.Trim() -notmatch '^(?i)d(etail)?$') { break } Write-Host "" Show-CATemplatePlan -CAAnswers $CAAnswers -Numbered } if ([string]::IsNullOrWhiteSpace($pickRaw)) { Write-Host " Nothing selected - cancelled." -ForegroundColor Gray Read-Host "`nPress Enter to return to the menu" | Out-Null return } $selectedSpec = @(if ($pickRaw.Trim() -match '^(?i)a(ll)?$') { $spec } else { $pickRaw -split '[,\s]+' | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $spec.Count } | ForEach-Object { $spec[$_] } }) if (-not $selectedSpec.Count) { Write-Host " No valid selection - cancelled." -ForegroundColor Yellow Read-Host "`nPress Enter to return to the menu" | Out-Null return } # 2026-09-15, per the maintainer: "for any 'template' we generate, we also need to allow a manual admin # request against it" - a per-group Auto template only ever grants Enroll/AutoEnroll to its own # pair's group; there was no way to manually request/troubleshoot that SAME group's cert shape # without falling back to the one generic shared Manual template. Opt-in (defaults No - this is # additive, not every group needs its own manual counterpart every time). $groupSpecsInSelection = @($selectedSpec | Where-Object { "$($_.Key)" -like 'Group_*' }) if ($groupSpecsInSelection.Count -and (Read-CAConfirm -Prompt "`nAlso add manual (admin-approved, exportable) version(s) for the $($groupSpecsInSelection.Count) selected group template(s)?")) { $manualAdds = @($groupSpecsInSelection | ForEach-Object { Get-CAPerGroupManualTemplateSpec -GroupSpec $_ }) $selectedSpec = @($selectedSpec) + $manualAdds } Write-Host "" Write-Host " Selected:" -ForegroundColor Cyan foreach ($s in $selectedSpec) { Write-Host " - $($s.DisplayName)" -ForegroundColor Gray } # 2026-09-11, per the maintainer's Y/N-defaults review ("what harm is it changing this to default Y? We # re-imported many times over the top when testing") - confirmed safe: New-CAVpnTemplate already # checks [ADSI]::Exists first and just skips (no error, no duplicate, no destructive rewrite) when # a complete template with this CN is already there - genuinely idempotent, matches the maintainer's own # live-tested experience re-running this menu repeatedly. if (-not (Read-CAConfirm -Prompt "`nCreate/publish the $($selectedSpec.Count) selected template(s) now?" -DefaultYes)) { Read-Host "`nPress Enter to return to the menu" | Out-Null return } $created = New-Object System.Collections.Generic.List[string] foreach ($t in $selectedSpec) { Write-Host "" Write-Host " --- $($t.DisplayName) ---" -ForegroundColor Cyan try { $cn = New-CAVpnTemplate -Spec $t foreach ($p in $t.EnrollPrincipals) { $who = switch ($p) { 'Umbrella' { $umbrella } 'OcspResponderHost' { $ocspHost } 'GroupSpecific' { $t.GroupName } # per-group Auto template (TameMyCerts model) - ACL to just this pair's AD group 'DomainAndEnterpriseAdmins' { $null } # Domain/Enterprise Admins already have Enroll via inherited ACEs on a fresh template base default { $p } } if ($who) { # Enroll-only at creation, always - NEVER -AutoEnroll here anymore. "The # enrollment gate" (Invoke-CAMenuAutoEnrollGate) is now the one place AutoEnroll # gets turned on, deliberately, per group, decoupled from template creation order. # $t.AutoEnrollPrincipals still names WHO should eventually get it - the gate menu # reads that, this call site no longer acts on it directly. Grant-CATemplateEnrollment -TemplateInternalName $cn -PrincipalName $who } } $created.Add($cn) } catch { Write-Host " ERROR on $($t.DisplayName): $($_.Exception.Message)" -ForegroundColor Red Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray if (-not (Read-CAConfirm -Prompt " Continue with the next template?" -DefaultYes)) { break } } } # Publish in ONE write - a per-template certutil -SetCATemplates loop loses all but the last # entry on a single DC (each call read-modify-writes certificateTemplates). if ($created.Count) { Write-Host "" try { Add-CAPublishedTemplate -TemplateName $created } catch { Write-Host " ERROR publishing templates to the CA: $($_.Exception.Message)" -ForegroundColor Red Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray } } # Sweep OID husks left by any failed create attempts this run or earlier. Write-Host "" try { Remove-CAOrphanTemplateOids | Out-Null } catch { Write-Host " (orphan-OID sweep skipped: $($_.Exception.Message))" -ForegroundColor DarkGray } Write-Host "" Write-Host "Done. Check with: certutil -CATemplates" -ForegroundColor Green Read-Host "Press Enter to return to the menu" | Out-Null } function Invoke-CAMenuAdHocTemplate { <# .SYNOPSIS Menu 17 (Part A item 5, 2026-09-10). Creates ONE certificate template OUTSIDE the RadiusGroupPairs-driven flow menu 4 owns - for the one-off "just need a template for X" ask, without hijacking the per-group/Auto/Manual/FortiGate/OCSP batch. Reuses New-CAVpnTemplate / Grant-CATemplateEnrollment / Add-CAPublishedTemplate / Remove-CAOrphanTemplateOids completely unchanged - only the spec comes from the new Get-CAAdHocTemplateSpec (CATemplates.ps1) instead of Get-CATemplateSpecForAnswers. Grants Enroll only, same as menu 4 - AutoEnroll is still the enrollment gate's job alone (menu 15). #> param($CAAnswers) Write-CAHeader "Add a new template ad-hoc" $purposes = @(Get-CAAdHocTemplatePurposes) if (-not $purposes.Count) { Write-Host " No ad-hoc template purposes configured (see Get-CAAdHocTemplatePurposes)." -ForegroundColor Red Read-Host "`nPress Enter to return to the menu" | Out-Null return } # A clean, undo-able SEQUENCE OF PROMPTS with no mutating action interleaved - same shape as menu # 14's back-nav rollout, and for the same reason it's safe here: nothing touches AD/the CA until # after every answer below is collected and the operator explicitly confirms the plan. $steps = @( @{ Name = 'DisplayName'; Run = { param($CanGoBack) Read-CANonEmpty -Prompt "Template display name" -AllowBack:$CanGoBack } } @{ Name = 'PurposeKey'; Run = { param($CanGoBack) if ($purposes.Count -eq 1) { Write-Host " Purpose: $($purposes[0].Label) (the only one configured today)" -ForegroundColor Gray return $purposes[0].Key } Write-Host "" for ($i = 0; $i -lt $purposes.Count; $i++) { Write-Host (" {0}. {1}" -f ($i + 1), $purposes[$i].Label) } $pick = Read-CAOptional -Prompt " Purpose number" -AllowBack:$CanGoBack if (Test-CABackSignal $pick) { return $pick } $idx = 0 if (-not [int]::TryParse($pick, [ref]$idx) -or $idx -lt 1 -or $idx -gt $purposes.Count) { Write-Host " Not a valid choice - defaulting to 1." -ForegroundColor Yellow $idx = 1 } $purposes[$idx - 1].Key } } @{ Name = 'ValidityDays'; Run = { param($CanGoBack) $r = Read-CAOptional -Prompt " Validity in days [365]" -AllowBack:$CanGoBack if (Test-CABackSignal $r) { return $r } $v = 0 if ([string]::IsNullOrWhiteSpace($r) -or -not [int]::TryParse($r, [ref]$v) -or $v -le 0) { 365 } else { $v } } } @{ Name = 'OverlapDays'; Run = { param($CanGoBack) $r = Read-CAOptional -Prompt " Renewal overlap in days [42]" -AllowBack:$CanGoBack if (Test-CABackSignal $r) { return $r } $v = 0 if ([string]::IsNullOrWhiteSpace($r) -or -not [int]::TryParse($r, [ref]$v) -or $v -le 0) { 42 } else { $v } } } @{ Name = 'ExportableKey'; Run = { param($CanGoBack) $r = Read-CAOptional -Prompt " Exportable private key? [y/N]" -AllowBack:$CanGoBack if (Test-CABackSignal $r) { return $r } [bool]($r -match '^(?i)y(es)?$') } } @{ Name = 'EnrollPrincipal'; Run = { param($CanGoBack) Read-CAOptional -Prompt " Grant Enroll to (DOMAIN\name or group - blank to skip for now)" -AllowBack:$CanGoBack } } ) $wiz = Invoke-CAWizardSteps -Steps $steps if ($wiz.OverlapDays -ge $wiz.ValidityDays) { Write-Host " Overlap ($($wiz.OverlapDays)d) must be LESS than validity ($($wiz.ValidityDays)d) - adjusting overlap down." -ForegroundColor Yellow $wiz.OverlapDays = [Math]::Max(1, [Math]::Floor($wiz.ValidityDays / 2)) } $spec = Get-CAAdHocTemplateSpec -DisplayName $wiz.DisplayName -PurposeKey $wiz.PurposeKey ` -ValidityDays $wiz.ValidityDays -OverlapDays $wiz.OverlapDays -ExportableKey:$wiz.ExportableKey $purposeLabel = ($purposes | Where-Object { $_.Key -eq $wiz.PurposeKey }).Label Write-Host "" Write-Host " --- Plan ---" -ForegroundColor Cyan Write-Host (" DisplayName : {0}" -f $spec.DisplayName) Write-Host (" Purpose : {0}" -f $purposeLabel) Write-Host (" Validity : {0}d (overlap {1}d)" -f $spec.ValidityDays, $spec.OverlapDays) Write-Host (" Exportable key : {0}" -f [bool]$wiz.ExportableKey) Write-Host (" Enroll -> : {0}" -f $(if ($wiz.EnrollPrincipal) { $wiz.EnrollPrincipal } else { '(none yet - skip for now)' })) Write-Host "" # Same idempotency reasoning as menu 4's batch create/publish prompt above - safe to default Yes. if (-not (Read-CAConfirm -Prompt "Create/publish this template now?" -DefaultYes)) { Read-Host "`nPress Enter to return to the menu" | Out-Null return } try { $cn = New-CAVpnTemplate -Spec $spec if ($wiz.EnrollPrincipal) { Grant-CATemplateEnrollment -TemplateInternalName $cn -PrincipalName $wiz.EnrollPrincipal } Add-CAPublishedTemplate -TemplateName @($cn) Write-Host "" Write-Host " Done. Check with: certutil -CATemplates" -ForegroundColor Green } catch { Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray } try { Remove-CAOrphanTemplateOids | Out-Null } catch { } Read-Host "`nPress Enter to return to the menu" | Out-Null } function Invoke-CAMenuUmbrella { param($CAAnswers) Write-CAHeader "Set template permissions (auto-enrollment umbrella group)" $dn = Resolve-CAUmbrellaGroup -SuggestedName $CAAnswers.CA_AutoEnrollGroup if ($dn) { Write-Host "" Write-Host "Umbrella group: $dn" -ForegroundColor Green Write-Host "Template creation already granted this group (and the others) Enroll on their" -ForegroundColor Gray Write-Host "matching templates. AutoEnroll is a separate, deliberate switch now - see the" -ForegroundColor Gray Write-Host "enrollment gate menu item to turn it on (or off) per group, whenever you're ready." -ForegroundColor Gray if ($CAAnswers -and -not $CAAnswers.CA_AutoEnrollGroup) { $name = ($dn -split ',')[0] -replace '^CN=' $CAAnswers | Add-Member -NotePropertyName CA_AutoEnrollGroup -NotePropertyValue $name -Force } } Read-Host "`nPress Enter to return to the menu" | Out-Null } function Resolve-CAAutoEnrollGatePrincipal { <# .SYNOPSIS Maps an AutoEnrollPrincipals token ('Umbrella' / 'OcspResponderHost' / 'GroupSpecific' / 'DomainAndEnterpriseAdmins' / a literal name) to an actual DOMAIN\name, mirroring the exact switch Invoke-CAMenuTemplates uses for EnrollPrincipals - the gate has to resolve to the SAME principal that got Enroll at creation time, or toggling it means nothing. #> param($Token, $Spec, $CAAnswers) switch ($Token) { 'Umbrella' { $CAAnswers.CA_AutoEnrollGroup } 'OcspResponderHost' { Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_OcspResponderHost' -Prompt "OCSP responder host/group (DOMAIN\name)" -Remember } 'GroupSpecific' { $Spec.GroupName } 'DomainAndEnterpriseAdmins' { $null } # already enrolled via inherited ACEs - nothing to gate default { $Token } } } function Invoke-CAMenuAutoEnrollGate { <# .SYNOPSIS "The enrollment gate" - lists every template that's SUPPOSED to auto-enroll someone (AutoEnrollPrincipals non-empty in Get-CATemplateSpecForAnswers) alongside whether that principal currently holds the AutoEnroll ACE, and lets the operator flip it on/off per row. Template creation (menu 4) only ever grants Enroll now - this is the one place AutoEnroll actually gets turned on, deliberately, decoupled from build order. #> param($CAAnswers, $Status) while ($true) { Write-CAHeader "The enrollment gate (AutoEnroll on/off, per group)" $spec = @(Get-CATemplateSpecForAnswers -CAAnswers $CAAnswers | Where-Object { $_.AutoEnrollPrincipals -and $_.AutoEnrollPrincipals.Count }) if (-not $spec.Count) { Write-Host " No templates in the current plan have an AutoEnroll principal configured - nothing to gate." -ForegroundColor Gray Read-Host "`nPress Enter to return to the menu" | Out-Null return } $rows = New-Object System.Collections.Generic.List[object] foreach ($t in $spec) { $cn = ($t.DisplayName -replace '[^A-Za-z0-9]', '') foreach ($p in $t.AutoEnrollPrincipals) { $who = Resolve-CAAutoEnrollGatePrincipal -Token $p -Spec $t -CAAnswers $CAAnswers if ([string]::IsNullOrWhiteSpace($who)) { continue } $on = $false try { $on = Test-CATemplateAutoEnroll -TemplateInternalName $cn -PrincipalName $who } catch { } $rows.Add([pscustomobject]@{ DisplayName = $t.DisplayName; Cn = $cn; Principal = $who; Enabled = $on }) } } if (-not $rows.Count) { Write-Host " Every AutoEnroll principal above is blank (umbrella group not set yet, etc.) - nothing to gate." -ForegroundColor Gray Read-Host "`nPress Enter to return to the menu" | Out-Null return } Write-Host "" for ($i = 0; $i -lt $rows.Count; $i++) { $r = $rows[$i] $state = if ($r.Enabled) { "ON " } else { "off" } $color = if ($r.Enabled) { 'Green' } else { 'DarkGray' } Write-Host (" {0}. [{1}] {2,-30} -> {3}" -f ($i + 1), $state, $r.DisplayName, $r.Principal) -ForegroundColor $color } Write-Host "" Write-Host " Enter a number to toggle that row, or Enter to return to the menu." -ForegroundColor Gray $pick = Read-Host "Choice" if ([string]::IsNullOrWhiteSpace($pick)) { return } $idx = 0 if (-not [int]::TryParse($pick, [ref]$idx) -or $idx -lt 1 -or $idx -gt $rows.Count) { Write-Host " Not a valid row number." -ForegroundColor Yellow Start-Sleep -Seconds 1 continue } $row = $rows[$idx - 1] $newState = -not $row.Enabled $verb = if ($newState) { 'GRANT' } else { 'REVOKE' } Write-Host "" Write-Host " $verb AutoEnroll for $($row.Principal) on $($row.DisplayName) (CN=$($row.Cn))" -ForegroundColor $(if ($newState) { 'Yellow' } else { 'Yellow' }) if (Read-CAConfirm -Prompt "Proceed?") { try { Set-CATemplateAutoEnroll -TemplateInternalName $row.Cn -PrincipalName $row.Principal -Enabled $newState Write-Host " Done." -ForegroundColor Green } catch { Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red } Start-Sleep -Seconds 1 } } } function Invoke-CAMenuUrls { param($CAAnswers, $Status) Write-CAHeader "Route AIA / CDP / OCSP through the App Proxy" $crlFqdn = Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_AppProxyCrlFqdn' -Prompt "App Proxy external hostname for CRL/AIA" -Remember $ocspFqdn = Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_AppProxyOcspFqdn' -Prompt "App Proxy external hostname for OCSP" -Remember $delta = -not ($CAAnswers.CA_DeltaCrl -match '^(?i)n') $sharePath = if ($CAAnswers.CA_CrlShareMode -and $CAAnswers.CA_CrlShareMode -notmatch '^(?i)none') { $CAAnswers.CA_CrlSharePath } else { $null } $plan = Get-CAPublicationUrlPlan -CrlFqdn $crlFqdn -OcspFqdn $ocspFqdn -DeltaCrl $delta -CrlSharePath $sharePath Write-Host "" Write-Host " CDP (CRLPublicationURLs):" -ForegroundColor White foreach ($e in $plan.Cdp) { Write-Host " $e" -ForegroundColor Gray } Write-Host " AIA (CACertPublicationURLs):" -ForegroundColor White foreach ($e in $plan.Aia) { Write-Host " $e" -ForegroundColor Gray } Write-Host "" Write-Host " ORDER-CRITICAL: run this BEFORE any certificate is issued - issued certs bake these URLs in." -ForegroundColor Yellow if (Read-CAConfirm -Prompt "`nApply (certutil -setreg + republish CRL + restart CertSvc)?") { Set-CAPublicationUrls -Plan $plan } Read-Host "`nPress Enter to return to the menu" | Out-Null } function Invoke-CAMenuAppProxy { <# .SYNOPSIS Menu 6 (2026-09-10 renumber - was menu 7). IIS /CertEnroll/ + the Entra private-network connector + connector group + the CRL and OCSP App Proxy apps. Steps 6a-6f, guided, every mutation dry-run aware. Resolves the real *.msappproxy.net hostnames and writes them back onto $CAAnswers (persisted by the dashboard) so menu 8 can route the CA's CDP/AIA/OCSP at them. #> param($CAAnswers, $Status) Write-CAHeader "App Proxy connector + Entra apps" Write-Host " This runs BEFORE menu 8 and before any certificate is issued: menu 8 bakes the" -ForegroundColor Yellow Write-Host " external hostnames resolved here into every issued cert's CDP/AIA/OCSP URLs." -ForegroundColor Yellow # IE Enhanced Security Configuration makes the Graph sign-in AND the connector installer's # modern-auth window go blank on a server. Offer to turn it off up front. $esc = Get-CAIEEnhancedSecurity if ($esc.Admin -eq $true -or $esc.User -eq $true) { Write-Host "" Write-Host " IE Enhanced Security Configuration is ON. The Graph sign-in and the connector" -ForegroundColor Yellow Write-Host " installer's auth window can come up blank because of it." -ForegroundColor Yellow if (Read-CAConfirm -Prompt " Turn IE ESC OFF now (explorer restarts briefly; re-enable later via menu 6 or Server Manager)?" -DefaultYes) { Set-CAIEEnhancedSecurity -Enabled $false } } # --- 6a connect + context ------------------------------------------------- # Back-navigation (2026-09-10, Part A item 3): only these 3 prompts get it, not the rest of this # wizard - 6b onward interleaves prompts with real mutating actions (IIS config, connector # install, Graph app publish), and "go back" can't undo an already-applied step, so offering it # there would be misleading. This trio is a genuine undo-able sequence with nothing mutating yet. $steps = @( @{ Name = 'GrpName'; Run = { param($CanGoBack) Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_AppProxyConnectorGroup' -Prompt "Connector group name" -Remember -AllowBack:$CanGoBack } } @{ Name = 'CrlAppName'; Run = { param($CanGoBack) Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_AppProxyCrlAppName' -Prompt "CRL app display name" -Remember -AllowBack:$CanGoBack } } @{ Name = 'OcspAppName'; Run = { param($CanGoBack) Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_AppProxyOcspAppName' -Prompt "OCSP app display name" -Remember -AllowBack:$CanGoBack } } ) $wiz6a = Invoke-CAWizardSteps -Steps $steps $grpName = $wiz6a.GrpName Write-Host "" Write-Host " Signing in to Microsoft Graph (Application Administrator)..." -ForegroundColor Gray $ctx = Connect-CAGraph if (-not $ctx) { Read-Host "`nPress Enter to return to the menu" | Out-Null; return } $missing = Test-CAGraphWriteScopes if ($missing.Count) { Write-Host " WARNING: the sign-in is missing write scopes: $($missing -join ', ')" -ForegroundColor Yellow Write-Host " Re-run and consent, or sign in as an account that can grant them." -ForegroundColor Yellow if (-not (Read-CAConfirm -Prompt " Continue anyway?" )) { Read-Host "`nPress Enter to return to the menu" | Out-Null; return } } $tenantLabel = if ($CAAnswers.CA_MsAppProxyTenant) { $CAAnswers.CA_MsAppProxyTenant } else { Get-CAGraphTenantInitialDomain } if ([string]::IsNullOrWhiteSpace($tenantLabel)) { $tenantLabel = Read-CANonEmpty -Prompt " msappproxy tenant label (the bit before .onmicrosoft.com)" } Write-Host (" Tenant label for *.msappproxy.net hostnames: {0}" -f $tenantLabel) -ForegroundColor Green if (-not (Read-CAConfirm -Prompt " Use it?" -DefaultYes)) { $tenantLabel = Read-CANonEmpty -Prompt " msappproxy tenant label" } if ($CAAnswers -and -not $CAAnswers.CA_MsAppProxyTenant) { $CAAnswers | Add-Member -NotePropertyName CA_MsAppProxyTenant -NotePropertyValue $tenantLabel -Force } $caHost = try { [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName } catch { "$env:COMPUTERNAME" } Write-Host (" This CA's internal FQDN (App Proxy backend): {0}" -f $caHost) -ForegroundColor Green if (-not (Read-CAConfirm -Prompt " Correct?" -DefaultYes)) { $caHost = Read-CANonEmpty -Prompt " CA internal FQDN" } Write-Host "" Write-Host " Existing App Proxy objects in this tenant:" -ForegroundColor Gray foreach ($g in @(Get-CAGraphAll "https://graph.microsoft.com/beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups" -Quiet)) { Write-Host (" group : {0}" -f $g.name) -ForegroundColor DarkGray } foreach ($c in @(Get-CAGraphAll "https://graph.microsoft.com/beta/onPremisesPublishingProfiles/applicationProxy/connectors" -Quiet)) { Write-Host (" connector : {0} ({1})" -f $c.machineName, $c.status) -ForegroundColor DarkGray } # --- 6b IIS /CertEnroll/ ------------------------------------------------ $physPath = if ($CAAnswers.CA_CrlShareMode -and $CAAnswers.CA_CrlShareMode -notmatch '^(?i)none' -and $CAAnswers.CA_CrlSharePath) { $CAAnswers.CA_CrlSharePath } else { '%windir%\System32\CertSrv\CertEnroll' } $webPlan = Get-CAWebEndpointPlan -PhysicalPath $physPath -CACommonName $Status.CACommonName Show-CAWebEndpointPlan -Plan $webPlan if (Read-CAConfirm -Prompt "`nConfigure the IIS /CertEnroll/ endpoint now?" -DefaultYes) { Set-CAWebEndpoint -Plan $webPlan } # --- 6c connector install + register ---------------------------------- Write-Host "" if ($Status.AppProxyConnectorInstalled -and $Status.AppProxyConnectorStatus -eq 'Running' -and (Get-CAAppProxyConnector -MachineName $caHost)) { Write-Host " Connector already installed, running, and registered - skipping 7c." -ForegroundColor Green } elseif (Read-CAConfirm -Prompt "Install + register the Entra private-network connector on this box?" -DefaultYes) { Write-Host " Tip: the surest source is the Entra admin center -> Enterprise applications -> Application" -ForegroundColor DarkGray Write-Host " proxy -> Download connector service. Auto-download tries the tenant URL + aka.ms links." -ForegroundColor DarkGray $preDl = Read-Host " Path to a pre-downloaded connector .exe (blank = auto-download)" if ([string]::IsNullOrWhiteSpace($preDl)) { Install-CAAppProxyConnector -TenantId $ctx.TenantId } else { Install-CAAppProxyConnector -TenantId $ctx.TenantId -InstallerPath $preDl.Trim('"') } Repair-CAModulePath # the connector installer rewrites PSModulePath and drops the CurrentUser path } # --- 6d connector group --------------------------------------------------- Write-Host "" $groupId = New-CAAppProxyConnectorGroup -Name $grpName $conn = Get-CAAppProxyConnector -MachineName $caHost if (-not $conn -and (Get-CADryRun)) { $conn = [pscustomobject]@{ id = '<connectorId>' } } # so the memberOf step previews if ($groupId -and $conn) { Add-CAAppProxyConnectorToGroup -ConnectorId $conn.id -GroupId $groupId } elseif (-not (Get-CADryRun)) { Write-Host " Could not resolve the connector or group id - finish 6c, then re-run menu 6." -ForegroundColor Yellow } # --- 6e the two apps ---------------------------------------------------- $appPlan = Get-CAAppProxyPlan -CAAnswers $CAAnswers -CAHostFqdn $caHost -TenantLabel $tenantLabel Show-CAAppProxyPlan -Plan $appPlan Write-Host "" Write-Host " Note: the OCSP app's /ocsp/ backend does not exist until menu 9 (Online Responder)." -ForegroundColor DarkYellow $results = @() if (Read-CAConfirm -Prompt "Create / update the CRL and OCSP-Relay apps?" -DefaultYes) { foreach ($spec in $appPlan.Apps) { Write-Host "" Write-Host " --- $($spec.DisplayName) ---" -ForegroundColor Cyan try { $results += Publish-CAAppProxyApp -AppSpec $spec -GroupId $groupId } catch { Write-Host " ERROR on $($spec.DisplayName): $($_.Exception.Message)" -ForegroundColor Red } } } # --- 6f write-back ---------------------------------------------------- Write-Host "" Write-Host " External hostnames:" -ForegroundColor Yellow foreach ($r in $results) { Write-Host (" {0,-11} {1}" -f $r.Key, $r.ActualExternalUrl) -ForegroundColor Yellow if ($r.ActualHost -and $CAAnswers) { $field = if ($r.Key -eq 'CRL') { 'CA_AppProxyCrlFqdn' } else { 'CA_AppProxyOcspFqdn' } $CAAnswers | Add-Member -NotePropertyName $field -NotePropertyValue $r.ActualHost -Force } } Write-Host "" Write-Host " These are now on the in-memory answers. Re-run menu 8 (Route AIA/CDP/OCSP through the" -ForegroundColor Yellow Write-Host " App Proxy) so the CA bakes them into issued certs - BEFORE issuing anything." -ForegroundColor Yellow Read-Host "`nPress Enter to return to the menu" | Out-Null } function Invoke-CAMenuOcsp { <# .SYNOPSIS Menu 9 (2026-09-10 renumber - was menu 4). Installs the Online Responder role and creates ONE revocation configuration for the local single-tier CA, so http://<host>/ocsp answers (and the Menu-6 OCSP-Relay app stops 502ing). Every mutation is dry-run aware; the signing-cert acquisition is a non-fatal nudge. #> param($CAAnswers, $Status) Write-CAHeader "Install / configure OCSP (Online Responder)" if (-not $Status.CACommonName) { Write-Host " No local CA detected - run menu 9 on the CA box." -ForegroundColor Yellow Read-Host "`nPress Enter to return to the menu" | Out-Null; return } if ($Status -and -not ($Status.HasExternalCDP -and $Status.HasExternalAIAorOCSP)) { Write-Host " NOTE: menu 8 (URL routing) hasn't run - the OCSP signing cert enrolled here will" -ForegroundColor DarkYellow Write-Host " carry the CA's current (non-App-Proxy) URLs. It's 14-day / auto-renewed so it" -ForegroundColor DarkYellow Write-Host " self-corrects, and it has ocsp-nocheck anyway - but running menu 8 first is tidier." -ForegroundColor DarkYellow Write-Host "" } $caFqdn = try { [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName } catch { "$env:COMPUTERNAME" } if (-not (Read-CAConfirm -Prompt " This CA / responder FQDN is '$caFqdn' - correct?" -DefaultYes)) { $caFqdn = Read-CANonEmpty -Prompt " CA / responder FQDN" } $sanitized = try { Get-CAActiveConfigName } catch { $Status.CACommonName } $configNC = try { Get-CAConfigNamingContext } catch { $null } $null = Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_TemplateOcspSigning' -Prompt "OCSP response-signing template display name" -Remember $delta = -not ($CAAnswers.CA_DeltaCrl -match '^(?i)n') $plan = Get-CAOcspPlan -CAAnswers $CAAnswers -CACommonName $Status.CACommonName ` -CAMachineFqdn $caFqdn -CASanitizedName $sanitized -ConfigNC $configNC -DeltaCrl $delta Show-CAOcspPlan -Plan $plan $t = Test-CAOcsp -Plan $plan -ExpectedConfigName $plan.ConfigName -ExpectedCAConfig $plan.CAConfig Write-Host "" Write-Host (" Current: role {0}, service {1}, /ocsp {2}, revocation config {3}, signing cert {4}" -f ` $t.RoleInstalled, $t.ServiceRunning, $t.IsapiAppPresent, $t.RevocationConfigForThisCA, $t.SigningCertAcquired) -ForegroundColor Gray if (Read-CAConfirm -Prompt "`nInstall the Online Responder role (if needed) and create this revocation configuration?" -DefaultYes) { Install-CAOcspRole New-CAOcspRevocationConfig -Plan $plan Confirm-CAOcspSigningCertificate -Plan $plan Set-CAOcspAuditing -Plan $plan Set-CAOcspWebRequestLimits -Plan $plan if (-not (Get-CADryRun)) { Write-Host "`n Waiting for the /ocsp proxy to pick up the config..." -ForegroundColor DarkGray # transient HTTP 500 (E_FAIL) for ~15s after OCSPSvc reloads - poll past it [void](Test-CAOcspEndpoint -Url $plan.LocalVerifyUrl -RetrySeconds 45) } $t2 = Test-CAOcsp -Plan $plan -ExpectedConfigName $plan.ConfigName -ExpectedCAConfig $plan.CAConfig Write-Host "" if ($t2.Working) { Write-Host " OCSP responder is Working - $($plan.ResponderUrl) answers for $($plan.CAConfig)." -ForegroundColor Green } else { Write-Host " Not fully Working yet:" -ForegroundColor Yellow Write-Host (" service {0} / config-for-this-CA {1} / signing cert {2} / endpoint {3}" -f ` $t2.ServiceRunning, $t2.RevocationConfigForThisCA, $t2.SigningCertAcquired, $t2.EndpointAnswers) -ForegroundColor Yellow } Write-Host (" Hardening: auditing {0} (AuditFilter={1}) / IIS request limits {2} (maxUrl={3}, maxQueryString={4})" -f ` $t2.AuditFilterOk, $t2.AuditFilterValue, $t2.IisLimitsOk, $t2.IisMaxUrlBytes, $t2.IisMaxQueryStringBytes) -ForegroundColor $(if ($t2.AuditFilterOk -and $t2.IisLimitsOk) { 'Gray' } else { 'DarkYellow' }) } Read-Host "`nPress Enter to return to the menu" | Out-Null } # --------------------------------------------------------------------------- # All numbered menus are wired (2026-09-12 renumber - old menu 10, the auto-enroll GPO push, MOVED # to the new AD-Manager tool and is no longer part of this dashboard at all): 1 CACore prereqs, # 2 CAInstall, 3 CATameMyCerts, 4/5 CATemplates, 6 CAAppProxy, 7 CACrlShare, 8 CAUrls, 9 CAOcsp, # 10 CAHealth, 11 CATestSuite, 12 CARenewalTest, 13 CAFortiGateHandoff, 14 the inventory report, # 15 the enrollment gate (CATemplates), 16 the ad-hoc template wizard (CATemplates). Sub-CA install # path is still deferred (Get-CAInstallPlan throws). # --------------------------------------------------------------------------- function Show-CAMenuHelp { <# .SYNOPSIS The longer rationale prose that used to live inline, next to every menu item, before the 2026-09-10 terse/verbose pass (Part A item 6 - The maintainer: "I like the '?' for more info"). CA-Manager.ps1 shows this automatically once per session (first render), then only on demand when the operator presses '?' - the numbered menu itself stays one line per item, matching NPS-Manager.ps1's own terse dashboard style. 2026-09-14: re-numbered throughout for the 2026-09-12 renumber (old menu 10, the GPO push, is gone - Toolkit shifted down by one) and gained its own "AD-Manager" section - this help screen is CA-Manager's single most-read piece of prose, so it's the right place for a tech to actually learn the sibling tool exists, not just infer it from a stray fix-suggestion string. #> Write-Host "" Write-Host " --- Why this order? ---" -ForegroundColor DarkCyan Write-Host " Setup's order (1-9) only reflects genuine AD/technical dependencies now, not a" -ForegroundColor Gray Write-Host " 'must happen in exactly this sequence or you'll get a bad first cert' requirement -" -ForegroundColor Gray Write-Host " that job belongs to item 15 alone (see below). Still, a sensible first pass:" -ForegroundColor Gray Write-Host "" Write-Host " 1 2 [3] 4 5 -> 6 -> [7] -> 8 -> 9 -> 10 -> [11] -> 13" -ForegroundColor Gray Write-Host " ([3]/[7]/[11] optional - TameMyCerts / split-tier CRL share / test certs)" -ForegroundColor DarkGray Write-Host "" Write-Host " - 4 creates templates, granting Enroll only (never AutoEnroll any more)" -ForegroundColor DarkGray Write-Host " - 8 needs 6's resolved *.msappproxy.net hostnames (and 7's share path, if split-tier)" -ForegroundColor DarkGray Write-Host " before it can route AIA/CDP/OCSP correctly - do it before any cert is issued," -ForegroundColor DarkGray Write-Host " since every issued cert bakes in whatever URLs were live at the time" -ForegroundColor DarkGray Write-Host " - 9 (OCSP) after 8, so the responder's own signing cert gets the right URLs too" -ForegroundColor DarkGray Write-Host " - the auto-enrollment GPO itself now lives in AD-Manager (see below), not here -" -ForegroundColor DarkGray Write-Host " link it whenever's convenient, since nothing actually auto-enrolls until 15 is" -ForegroundColor DarkGray Write-Host " flipped on for a given group" -ForegroundColor DarkGray Write-Host "" Write-Host " --- The enrollment gate (15) ---" -ForegroundColor DarkCyan Write-Host " Enroll and AutoEnroll are deliberately separate now. Item 4 grants Enroll once, at" -ForegroundColor Gray Write-Host " creation, permanently. AutoEnroll is a dedicated on/off switch, per group, at item" -ForegroundColor Gray Write-Host " 15 - nothing auto-enrolls for real until you flip it there, verified clean via 10" -ForegroundColor Gray Write-Host " (health check) first. That's what makes the Setup order above safe to revisit out" -ForegroundColor Gray Write-Host " of sequence - not the order itself." -ForegroundColor Gray Write-Host "" Write-Host " --- Rollout (17) ---" -ForegroundColor DarkCyan Write-Host " Once Setup is done: batch-issue PFX files for vendors from the manual-approval" -ForegroundColor Gray Write-Host " templates - one output folder and run password per batch, template/user/password per" -ForegroundColor Gray Write-Host " vendor, then one approval pass in certsrv.msc. Nothing is remembered between runs." -ForegroundColor Gray Write-Host "" Write-Host " --- AD-Manager (sibling tool) ---" -ForegroundColor DarkCyan Write-Host " PushableTools\ADManager\ handles AD/GPO scaffolding this dashboard used to do a piece" -ForegroundColor Gray Write-Host " of and never fully owned: standing up a client's VPN group/OU structure (main group +" -ForegroundColor Gray Write-Host " the VPNFW purpose groups), pushing/relinking the certificate auto-enrollment GPO" -ForegroundColor Gray Write-Host " (moved there outright, 2026-09-12 - it's no longer a menu item here at all), and the" -ForegroundColor Gray Write-Host " well-known WMI filters. Reach for it before item 5 (the umbrella/permissions group) if" -ForegroundColor Gray Write-Host " the group structure a client needs doesn't exist yet - it's the fuller, OU-aware tool" -ForegroundColor Gray Write-Host " for that, not a one-off typed group name." -ForegroundColor Gray Write-Host "" Write-Host " Press '?' any time to see this again; Enter to dismiss." -ForegroundColor DarkGray Read-Host | Out-Null } # --------------------------------------------------------------------------- function Test-CAHelpAcknowledged { <# .SYNOPSIS Whether this box has already acknowledged the "why this order?" intro (CA_HelpAcknowledged = Yes in CAAnswers.json) - PERSISTED, not per-session. 2026-09-11, per the maintainer: the old $script:CAHelpShownThisSession flag reset on every relaunch, so the intro reappeared every single time (including via the new menu-1 "relaunch CA-Manager" flow) - annoying on a box a tech is already familiar with. Read-only; pairs with Set-CAHelpAcknowledged below. #> param($CAAnswers) [bool]($CAAnswers -and $CAAnswers.PSObject.Properties['CA_HelpAcknowledged'] -and "$($CAAnswers.CA_HelpAcknowledged)" -match '^(?i)y') } function Set-CAHelpAcknowledged { <# .SYNOPSIS Records that this box has seen the intro, writing CAAnswers.json DIRECTLY - deliberately NOT through Invoke-CAStep/Save-CAAnswers. CA-Manager starts every session in DRY RUN by default, and Invoke-CAStep never runs its -Action in DRY RUN - routing this through the normal Save-CAAnswers path would mean the very first session (the one where the intro actually shows) can never actually persist the acknowledgment, defeating the whole point. This is tool-UX preference state, not a mutation of the target CA/AD environment - the DRY RUN gate exists to protect the latter, not the former, so bypassing it here is deliberate, not an oversight. Serializes with the exact same shape Save-CAAnswers uses (ConvertTo-Json -Depth 6, CRLF- normalized, UTF8) so the two writers never fight over file format. .OUTPUTS The (possibly newly-created) $CAAnswers object, so the caller's own variable stays in sync - PowerShell hashtables/pscustomobjects are reference types for in-place property sets, but a caller starting from $null needs the new object handed back explicitly. #> param($CAAnswers, [Parameter(Mandatory)][string]$Path) if (-not $CAAnswers) { $CAAnswers = [pscustomobject]@{} } if ($CAAnswers.PSObject.Properties['CA_HelpAcknowledged']) { $CAAnswers.CA_HelpAcknowledged = 'Yes' } else { $CAAnswers | Add-Member -NotePropertyName CA_HelpAcknowledged -NotePropertyValue 'Yes' -Force } try { $json = $CAAnswers | ConvertTo-Json -Depth 6 $json = ($json -replace "`r`n", "`n") -replace "`n", "`r`n" Set-Content -Path $Path -Value $json -Encoding UTF8 } catch { Write-Host " WARNING: could not save CA_HelpAcknowledged to $Path ($($_.Exception.Message)) - the intro will show again next launch." -ForegroundColor Yellow } return $CAAnswers } |