Private/OrchestratorImport.ps1
|
<#
.SYNOPSIS Option 3 - "Import Kickstart Definitions" - reads Master Orchestrator's per-client ClientAnswers/*.json files and imports their already-known RADIUS settings (FortiGate internal IP, shared secret, required AD group, VSA group name) into NPS, so a tech who already ran Master Orchestrator for a client doesn't have to re-type the same values a second time here. Named for what it actually covers now - a Shared Secret Template, a RADIUS Client Template, the live RADIUS Client itself, AND optionally its NPS rule(s) - not just "rules", which is all the older name ("Import kickstarted rules") implied. .DESCRIPTION Requires Modules\NPSCore.ps1 AND Modules\NPSInteractive.ps1 to already be dot-sourced (uses Get-ADGroupSID, Add-NPSClientFromTemplate, New-NPSSharedSecretTemplate, New-NPSClientTemplate, Set-NPSSharedSecretTemplateValue, Set-NPSClientTemplateAttribute, Get-NPSSharedSecretTemplates, Get-NPSClientTemplates, Set-NPSClientAttribute, Get-NPSClients, Add-NPSPolicySet, Add-NPSSingleRule, Get-NPSExistingSequences, Read-ANDConditionGroups, Get-VsaNamesForSide, Write-NPSHeader, Get-NPSTemplatesPathFor). TEMPLATES-FIRST (per the maintainer's explicit choice over a lighter "optional extra step" alternative): every import creates/updates a Shared Secret Template and a RADIUS Client Template from the imported data FIRST, then provisions the live RADIUS Client FROM that template (Add-NPSClientFromTemplate) rather than directly - so the imported config is reusable for a second client/server later (a backup NPS box, an additional FortiGate) without re-typing anything, matching how Option 4's own "Manage Templates" -> "Add a Client from a template" flow already works. Naming convention: "<ClientName>-Secret" for the Shared Secret Template, "<ClientName>" for the RADIUS Client Template - <ClientName> is whatever the tech confirms/enters in Step 1, defaulting to Master Orchestrator's own suggested RADIUSNPSFGTName. Master Orchestrator's ClientAnswers schema tracks a PRIMARY AD group / VSA pair per client (Auth_UserGroup_Name / Auth_UserGroup_Value), plus an OPTIONAL separate SSLVPN-side value (Auth_UserGroup_Value_SSLVPN, renamed from Auth_UserGroup_Name_SSLVPN 2026-08-20 - it was never a FortiGate object name, just an AD group name doing double duty as the SSLVPN VSA seed). When a client captured that separate value (confirmed real via actual client NPS exports - real clients use genuinely different VSAs per side), it's used to seed the SSLVPN side's baseline instead of reusing the IPSec one. Absent for clients saved before this field existed, or that don't need simultaneous SSLVPN+IPSec support - falls back to reusing the primary pair's value for both sides in that case, same as it always has. Either way, you can layer EXTRA AND-conditions on top of the imported baseline per side if needed. RADIUS_FGTInt_IP/RADIUS_NPS_FGTName default to Contoso/DEMOCLIENT-derived placeholder text ("192.168.x.xxx" / "Contoso_FGTXXY") on any client where RADIUS was never actually walked through the CLI Builder yet - detected here by the IP containing a literal 'x' - and flagged clearly rather than silently imported as if it were real, live data. Deliberately does NOT hardcode a path to the ClientAnswers folder - Master Orchestrator's own $AnswersDir is just "$BaseDir\ClientAnswers", relative to wherever THAT toolset happens to be deployed (a share, a tech's local copy, etc.), not a fixed location this NPS-server-resident script could assume. Callers must supply -AnswersPath (NPS-Manager.ps1's Option 3 prompts for it) - THAT live-folder-based flow is the standalone-usable path (works with zero staging/setup, matching The maintainer's explicit requirement that NPSManager keep working as a grab-and-run tool outside Master Orchestrator too). ALSO supports a second, additive path: a client's data pre-baked into a small NPSAnswers.json file sitting next to NPS-Manager.ps1 (see Export-NPSOrchestratorAnswerFile / Get-NPSBakedInAnswers below) - meant for when this NPS Manager package gets physically separated from the rest of Master Orchestrator's staging output and copied onto a standalone NPS server that has no network path back to ClientAnswers at all. When present, Option 3 uses it directly instead of prompting for -AnswersPath. This is ADDITIVE, not a replacement - its absence just falls back to the live-folder flow exactly as before, so a plain copy of NPS-Manager.ps1 run on its own keeps working unchanged. #> # --------------------------------------------------------------------------- function Get-NPSOrchestratorClients { <# .SYNOPSIS Reads every ClientAnswers/*.json file under -AnswersPath and returns the RADIUS-relevant subset of fields for clients whose AuthType is "RADIUS" (AuthType is the reliable signal here - NOT "NeedsRadius", which tracks whether THIS RUN of the CLI Builder still needed to configure RADIUS, not whether the client uses it at all - confirmed against IPSec-MasterOrchestrator.ps1's own use of that flag; a client can be NeedsRadius=false and AuthType=RADIUS simultaneously, meaning RADIUS was already fully set up in an earlier pass). Flags each result IsComplete=$true only if the IP isn't a placeholder, AND a secret is present, AND a required group name is present - callers should warn distinctly on IsComplete=$false rather than silently importing placeholder data as if it were real. #> param([Parameter(Mandatory)][string]$AnswersPath) if (-not (Test-Path $AnswersPath)) { throw "ClientAnswers path not found: $AnswersPath" } $files = Get-ChildItem -Path $AnswersPath -Filter '*.json' -File -ErrorAction SilentlyContinue $results = foreach ($file in $files) { try { $json = Get-Content -Path $file.FullName -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { continue # skip unparseable/unrelated files rather than aborting the whole listing } if ($json.AuthType -ne 'RADIUS') { continue } # Local-auth clients have nothing relevant here ConvertTo-NPSOrchestratorClient -Json $json -FileName $file.Name -FullPath $file.FullName } return @($results) } function ConvertTo-NPSOrchestratorClient { <# .SYNOPSIS One ClientAnswers object -> the RADIUS-relevant answer shape (see Get-NPSOrchestratorClients), with IsComplete. Split out (NSP.NPS, 2026-10-01) so the Orchestrator and a single-client caller (ConvertTo-NSPNpsAnswers) share one mapping instead of hand copies. #> param([Parameter(Mandatory)]$Json, [string]$FileName = '', [string]$FullPath = '') & { $json = $Json $file = [pscustomobject]@{ Name = $FileName; FullName = $FullPath } $ip = $json.RADIUS_FGTInt_IP $isPlaceholderIp = [string]::IsNullOrWhiteSpace($ip) -or ($ip -match '[xX]') $hasSecret = -not [string]::IsNullOrWhiteSpace($json.RADIUS_Secret) $hasGroup = -not [string]::IsNullOrWhiteSpace($json.Auth_UserGroup_Name) [pscustomobject]@{ CompanyName = $json.Company_Name FileName = $file.Name FullPath = $file.FullName RADIUSFGTIntIP = $ip RADIUSSecret = $json.RADIUS_Secret RADIUSNPSFGTName = $json.RADIUS_NPS_FGTName AuthUserGroupName = $json.Auth_UserGroup_Name AuthUserGroupValue = $json.Auth_UserGroup_Value # Blank/absent for every client onboarded before this field existed, or one that doesn't # need simultaneous SSLVPN+IPSec support - Invoke-ImportKickstartDefinitions falls back # to reusing AuthUserGroupValue for both sides in that case, same as it always has. # # Field renamed Auth_UserGroup_Name_SSLVPN -> Auth_UserGroup_Value_SSLVPN (2026-08-20) - # never a FortiGate object name (no SSLVPN-side "config user group" is ever created), it's # an AD group name that doubles as the VSA seed for the SSLVPN side, same as # Auth_UserGroup_Value is for IPSec. Reads the new key first, falls back to the old one so # already-saved ClientAnswers files keep working unchanged. AuthUserGroupValueSSLVPN = if ($json.PSObject.Properties['Auth_UserGroup_Value_SSLVPN']) { $json.Auth_UserGroup_Value_SSLVPN } else { $json.Auth_UserGroup_Name_SSLVPN } # Blank/absent for every client saved before multi-group-pair support existed - # Invoke-ImportKickstartDefinitions synthesizes a single implicit pair from the flat # AuthUserGroupName/Value/ValueSSLVPN fields above in that case. RadiusGroupPairs = $json.RadiusGroupPairs IPSecTunnelName = $json.IPSecTunnelName SSLTunnelName = $json.SSLTunnelName IsComplete = (-not $isPlaceholderIp) -and $hasSecret -and $hasGroup } } } # --------------------------------------------------------------------------- function Export-NPSOrchestratorAnswerFile { <# .SYNOPSIS Writes ONE client's already-known RADIUS answer data (same shape Get-NPSOrchestratorClients produces) to a small, self-contained JSON file - meant to be staged alongside NPS-Manager.ps1 so it keeps working with zero network path back to ClientAnswers, e.g. once copied off onto a standalone NPS server. Intended caller: Master Orchestrator's own staging step (once wired in - not yet), but works equally well run by hand for a one-off package. .PARAMETER AnswersPath Master Orchestrator's ClientAnswers folder (reachable from wherever THIS is run - normally the tech's own machine during staging, not the eventual NPS server). .PARAMETER CompanyName Which client to export - matched against Company_Name (case-insensitive), same value shown by Get-NPSOrchestratorClients. .PARAMETER OutputPath Where to write the answer file. Defaults to "NPSAnswers.json" - Get-NPSBakedInAnswers looks for exactly that filename next to NPS-Manager.ps1, so don't rename it unless you also update that lookup. #> param( [Parameter(Mandatory)][string]$AnswersPath, [Parameter(Mandatory)][string]$CompanyName, [string]$OutputPath = "NPSAnswers.json" ) $clients = Get-NPSOrchestratorClients -AnswersPath $AnswersPath $match = $clients | Where-Object { $_.CompanyName -eq $CompanyName } | Select-Object -First 1 if (-not $match) { throw "No RADIUS-auth client named '$CompanyName' found under '$AnswersPath'." } # Drop FileName/FullPath - those are only meaningful on the machine that had ClientAnswers # reachable in the first place, and would be actively misleading baked into a package that's # explicitly meant to travel to a machine WITHOUT that reachability. $exportable = $match | Select-Object CompanyName, RADIUSFGTIntIP, RADIUSSecret, RADIUSNPSFGTName, AuthUserGroupName, AuthUserGroupValue, AuthUserGroupValueSSLVPN, RadiusGroupPairs, IPSecTunnelName, SSLTunnelName, IsComplete $exportable | ConvertTo-Json | Set-Content -Path $OutputPath -Encoding UTF8 return [pscustomobject]@{ CompanyName = $CompanyName; OutputPath = (Resolve-Path $OutputPath).Path; IsComplete = $match.IsComplete } } # --------------------------------------------------------------------------- function Get-NPSBakedInAnswers { <# .SYNOPSIS Looks for a pre-staged NPSAnswers.json (see Export-NPSOrchestratorAnswerFile) next to NPS-Manager.ps1. Returns $null (not an error) if absent - that's the normal case for a standalone/grab-and-run copy, and callers should fall back to the live-folder-prompt flow. #> param([string]$ScriptRoot = $PSScriptRoot) # NSP.NPS: the work folder's Answers.json (seeded by a launcher) first, then the zip-era name. $path = $null foreach ($candidate in @((Join-Path $ScriptRoot "Answers.json"), (Join-Path $ScriptRoot "NPSAnswers.json"))) { if (Test-Path $candidate) { $path = $candidate; break } } if (-not $path) { return $null } try { return Get-Content -Path $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { Write-Host "Found NPSAnswers.json at $path but couldn't parse it - $($_.Exception.Message)" -ForegroundColor Yellow return $null } } # --------------------------------------------------------------------------- function Write-NPSPairImportProgress { <# .SYNOPSIS "Where am I / what's already decided" dashboard for ONE RADIUS group pair's own import Q&A - mirrors CLIBuilder's own Write-CLIBuilderProgress (2026-08-31 per the maintainer: "show me all the things we're answering up above and highlight the one we're answering"). Printed at the top of every step's own screen inside Invoke-SingleNPSPairImport, same "unconditional, not an opt-in peek" convention CLIBuilder settled on. .PARAMETER CurrentStep Which step's row (if any) gets the `***`/highlight treatment - one of 'Simul', 'AddMore', 'IpsecVsa', 'SslvpnVsa', 'BaseName', 'Sequence', or $null/blank for none. #> param( [Parameter(Mandatory)][string]$PairLabel, [Parameter(Mandatory)][string]$ResolvedGroupDisplay, [bool]$IsTriplicateKnown, [bool]$IsTriplicate, [string]$AddMoreGroupsAnswer, [string[]]$IpsecVsas, [string[]]$SslvpnVsas, [string]$BaseName, [string]$InsertAt, [string]$CurrentStep ) function Format-NPSProgressCell { param([string]$Text, [int]$Width, [int]$MinGap = 2) if ($null -eq $Text) { $Text = "" } $padCount = [Math]::Max($MinGap, $Width - $Text.Length) return $Text + (' ' * $padCount) } function New-NPSProgressRow { param([string]$Key, [string]$Label, [string]$Value) [pscustomobject]@{ Key = $Key; Label = $Label; Value = $Value } } $pending = "(not answered yet)" $rows = [System.Collections.Generic.List[object]]::new() $rows.Add((New-NPSProgressRow $null "Resolved AD Group:" $ResolvedGroupDisplay)) $rows.Add((New-NPSProgressRow 'Simul' "Simultaneous SSLVPN+IPSec:" $(if ($IsTriplicateKnown) { if ($IsTriplicate) { "Yes" } else { "No" } } else { $pending }))) $rows.Add((New-NPSProgressRow 'AddMore' "Additional required group(s):" $(if ($AddMoreGroupsAnswer) { $AddMoreGroupsAnswer } else { $pending }))) $rows.Add((New-NPSProgressRow 'IpsecVsa' "IPSec VSA(s):" $(if ($IpsecVsas -and $IpsecVsas.Count -gt 0) { $IpsecVsas -join ', ' } else { $pending }))) if ($IsTriplicateKnown -and $IsTriplicate) { $rows.Add((New-NPSProgressRow 'SslvpnVsa' "SSLVPN VSA(s):" $(if ($SslvpnVsas -and $SslvpnVsas.Count -gt 0) { $SslvpnVsas -join ', ' } else { $pending }))) } $rows.Add((New-NPSProgressRow 'BaseName' "Policy Base Name:" $(if ($BaseName) { $BaseName } else { $pending }))) $rows.Add((New-NPSProgressRow 'Sequence' "Insert Sequence:" $(if ($InsertAt) { $InsertAt } else { $pending }))) Write-Host "================================================================================" -ForegroundColor Cyan Write-Host " Importing '$PairLabel' - Progress So Far" -ForegroundColor Cyan Write-Host "================================================================================" -ForegroundColor Cyan foreach ($r in $rows) { $isCurrent = $CurrentStep -and $r.Key -eq $CurrentStep $marker = if ($isCurrent) { "*** " } else { " " } $label = Format-NPSProgressCell $r.Label 32 -MinGap 1 $line = " $marker$label$($r.Value)" if ($isCurrent) { Write-Host $line -ForegroundColor Green } else { Write-Host $line } } Write-Host "================================================================================" -ForegroundColor Cyan Write-Host "" } # --------------------------------------------------------------------------- function Invoke-SingleNPSPairImport { <# .SYNOPSIS Imports ONE RADIUS group pair's NPS policy set/rule - extracted 2026-08-31 (per the maintainer, same request as the pair-picker in Invoke-ImportKickstartDefinitions and the progress dashboard above: "let's also allow us to go back and edit a messed up answer before import") from what used to be one big inline `foreach` loop body, so it could become its own back-navigable step sequence and be called once per pair the tech actually picks, instead of always running front-to-back over every pair in one committed batch. .DESCRIPTION Steps ('Simul' / 'AddMore' / 'IpsecVsa' / 'SslvpnVsa' / 'BaseName' / 'Sequence') are walked by index, same "recompute fresh, re-render the dashboard, allow B to step back one" convention CLIBuilder's own $EntryIdx walkthrough uses - going back always re-does the step you land on from scratch. 'SslvpnVsa' only exists once 'Simul' has been answered Yes - skipped transparently in whichever direction it's approached from. Answering back INTO 'Simul' and changing it (or changing 'AddMore') resets every step's own answer that came after it, so nothing downstream is ever left stale after an upstream edit - required group/VSA sets are rebuilt from the resolved primary group fresh each time 'Simul' completes. AD resolution of the pair's own primary (and, if triplicate, SSLVPN-side) group happens ONCE up front, outside the back-navigable steps - it's a fact about the pair, not a question with a typed answer to revise, same as CLIBuilder treats Company_Name as fixed dashboard header info rather than a walkable field. .OUTPUTS [pscustomobject]@{ Completed = <bool - did this pair actually get committed>; ADServer = <possibly-updated>; ADCredential = <possibly-updated> } #> param( [Parameter(Mandatory)][pscustomobject]$Pair, [Parameter(Mandatory)][string]$IASConfigPath, [Parameter(Mandatory)][string]$ClientIPAddress, [string]$ADServer, [System.Management.Automation.PSCredential]$ADCredential, [string]$ShimPath ) # AD resolution below uses UserGroupVALUE, not UserGroupName - REAL BUG, confirmed live # 2026-08-31 against a real client's actual AD (the maintainer): UserGroupName is the FortiGate's own local # `config user group` object name (e.g. "FGT_IKEv2_CorpLan_Users") - purely a FortiGate-side # identifier CLIBuilder uses for `edit "..."`/`set groups "..."`, NEVER a real AD-resolvable # group. UserGroupValue IS the real AD group (and, per the maintainer, the same value minus possible # case doubles as the VSA NPS sends back - "the AD Group name and VSA value should be the same # (minus possible case difference)"). This whole per-pair AD-resolution block used to search AD # for UserGroupName instead, which only ever "worked" for a client whose FortiGate object # happened to be named identically to its real AD group - failed loudly for a client's real # FGT_-prefixed naming convention ("Cannot find an object with identity: 'FGT_IKEv2_CorpLan_Users'"). if ([string]::IsNullOrWhiteSpace($Pair.UserGroupValue)) { Write-Host "No required AD group captured for pair '$($Pair.Label)' - skipping. Use Option 5 to add it manually instead." -ForegroundColor Yellow Read-Host "Press Enter to continue" return [pscustomobject]@{ Completed = $false; ADServer = $ADServer; ADCredential = $ADCredential } } # IncludeInNPSImport (2026-08-21, custom app-access rules) - a pair captured with this # explicitly set to $false (CLIBuilder's Select-OrCreateGroupPair defaults new app-rule groups # to $false) is skipped SILENTLY here - no preview, no Y/N prompt at all. A pair with the # property absent entirely (every pair saved before this field existed) or explicitly $true is # processed exactly as before. if ($Pair.PSObject.Properties['IncludeInNPSImport'] -and $null -ne $Pair.IncludeInNPSImport -and -not [bool]$Pair.IncludeInNPSImport) { Write-Host "Pair '$($Pair.Label)' is excluded from batch import - add manually via NPS Manager Option 5 if needed." -ForegroundColor Gray Read-Host "Press Enter to continue" return [pscustomobject]@{ Completed = $false; ADServer = $ADServer; ADCredential = $ADCredential } } # Exact-name resolution first, falling back to Resolve-NPSGroupInteractive's search/pick when # that misses (a typo, a rename since the name was captured, different capitalization, etc.) # instead of just failing the whole pair - confirmed live (the maintainer, 2026-08-18) this was the # actual gap: the group was genuinely findable by a partial-name search, but importing just # skipped it and sent the tech to Option 5 to fix by hand. $resolveResult = Resolve-NPSGroupInteractive -GroupNameOrSID $Pair.UserGroupValue -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath $ADServer = $resolveResult.ADServer $ADCredential = $resolveResult.ADCredential if (-not $resolveResult.Resolved) { Write-Host "Could not resolve '$($Pair.UserGroupValue)' for pair '$($Pair.Label)' - skipping. Use Option 5 to add it manually instead." -ForegroundColor Red Read-Host "Press Enter to continue" return [pscustomobject]@{ Completed = $false; ADServer = $ADServer; ADCredential = $ADCredential } } $importedSid = $resolveResult.SID if ($resolveResult.Name -ne $Pair.UserGroupValue) { Write-Host "Using '$($resolveResult.Name)' (matched via search) in place of '$($Pair.UserGroupValue)'." -ForegroundColor Green } else { Write-Host "Resolved imported group '$($Pair.UserGroupValue)' -> $importedSid" -ForegroundColor Green } $resolvedGroupDisplay = "$($resolveResult.Name) ($importedSid)" $hasSeparateSslvpnGroup = -not [string]::IsNullOrWhiteSpace($Pair.UserGroupValueSSLVPN) # --- Back-navigable step sequence --- $isTriplicateKnown = $false $isTriplicate = $false $addMoreAnswer = $null $ipsecSidSets = $null; $ipsecNames = $null $sslvpnSidSets = $null; $sslvpnNames = $null $ipsecVsas = $null; $sslvpnVsas = $null $baseName = $null $insertAt = $null $stepNames = @('Simul', 'AddMore', 'IpsecVsa', 'SslvpnVsa', 'BaseName', 'Sequence') $stepIdx = 0 while ($stepIdx -lt $stepNames.Count) { $step = $stepNames[$stepIdx] cls Write-NPSPairImportProgress -PairLabel $Pair.Label -ResolvedGroupDisplay $resolvedGroupDisplay ` -IsTriplicateKnown $isTriplicateKnown -IsTriplicate $isTriplicate -AddMoreGroupsAnswer $addMoreAnswer ` -IpsecVsas $ipsecVsas -SslvpnVsas $sslvpnVsas -BaseName $baseName -InsertAt $insertAt -CurrentStep $step $canGoBack = $stepIdx -gt 0 $goBack = $false switch ($step) { 'Simul' { $simulDefaultIsYes = if ($null -ne $Pair.IsTriplicate) { [bool]$Pair.IsTriplicate } else { $true } $simulSupportDefaultHint = if ($null -ne $Pair.IsTriplicate) { if ($simulDefaultIsYes) { "captured as YES when this pair was set up" } else { "captured as NO when this pair was set up" } } elseif ($hasSeparateSslvpnGroup) { "this client has a separate SSLVPN group captured ('$($Pair.UserGroupValueSSLVPN)')" } else { "this client has both an IPSecTunnelName and SSLTunnelName on file" } $simulSupport = Read-Host "Are you needing to simultaneously support SSLVPN and IPSEC through RADIUS for '$($Pair.Label)'? (Y/N$(if ($canGoBack) { '/B to go back' }))`n (Enter defaults to $(if ($simulDefaultIsYes) { 'Yes' } else { 'No' }) - $simulSupportDefaultHint)" if ($canGoBack -and $simulSupport -match '^[Bb](ack)?$') { $goBack = $true } else { $isTriplicate = if ([string]::IsNullOrWhiteSpace($simulSupport)) { $simulDefaultIsYes } else { $simulSupport -match '^[Yy]' } $isTriplicateKnown = $true # (Re)seed the IPSec/SSLVPN SID/name lists fresh from the resolved primary group - # redone every time this step completes (first pass OR after backing up and # re-answering) so a later "additional groups" answer never accumulates onto a # stale base list. $ipsecSidSets = [System.Collections.Generic.List[string[]]]::new() $ipsecSidSets.Add(@($importedSid)) $ipsecNames = [System.Collections.Generic.List[string]]::new() $ipsecNames.Add($Pair.UserGroupValue) $sslvpnSidSets = [System.Collections.Generic.List[string[]]]::new() $sslvpnNames = [System.Collections.Generic.List[string]]::new() $sslvpnBaselineSid = $importedSid $sslvpnBaselineName = $Pair.UserGroupValue if ($isTriplicate -and $hasSeparateSslvpnGroup) { $sslvpnResolveResult = Resolve-NPSGroupInteractive -GroupNameOrSID $Pair.UserGroupValueSSLVPN -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath $ADServer = $sslvpnResolveResult.ADServer $ADCredential = $sslvpnResolveResult.ADCredential if ($sslvpnResolveResult.Resolved) { $sslvpnBaselineSid = $sslvpnResolveResult.SID $sslvpnBaselineName = $sslvpnResolveResult.Name if ($sslvpnResolveResult.Name -ne $Pair.UserGroupValueSSLVPN) { Write-Host "Using '$($sslvpnResolveResult.Name)' (matched via search) in place of '$($Pair.UserGroupValueSSLVPN)' for the SSLVPN side." -ForegroundColor Green } else { Write-Host "Resolved imported SSLVPN-side group '$($Pair.UserGroupValueSSLVPN)' -> $sslvpnBaselineSid" -ForegroundColor Green } } else { Write-Host "Could not resolve SSLVPN-side group '$($Pair.UserGroupValueSSLVPN)'." -ForegroundColor Yellow Write-Host "Falling back to '$($Pair.UserGroupValue)' for the SSLVPN side too - fix and re-run Option 3, or adjust manually via Option 5, if that's not right." -ForegroundColor Yellow } Read-Host "Press Enter to continue" } $sslvpnSidSets.Add(@($sslvpnBaselineSid)) $sslvpnNames.Add($sslvpnBaselineName) # Everything downstream of this step gets re-answered, not left stale - an edited # Simul answer can change whether the SSLVPN VSA step even exists at all. $addMoreAnswer = $null; $ipsecVsas = $null; $sslvpnVsas = $null; $baseName = $null; $insertAt = $null } } 'AddMore' { $addMore = Read-Host "Add additional required group(s) on top of the imported one for '$($Pair.Label)'? (Y/N$(if ($canGoBack) { '/B to go back' }))" if ($canGoBack -and $addMore -match '^[Bb](ack)?$') { $goBack = $true } else { if ($addMore -match '^[Yy]') { $addMoreAnswer = 'Yes' Write-NPSHeader "Additional IPSec Conditions - $($Pair.Label)" $extraIpsec = Read-ANDConditionGroups -SideLabel "IPSec (additional)" -ADServer $ADServer -ADCredential $ADCredential foreach ($s in $extraIpsec.SidSets) { $ipsecSidSets.Add($s) } $ipsecNames.AddRange([string[]]$extraIpsec.RequiredNames) if ($isTriplicate) { Write-NPSHeader "Additional SSLVPN Conditions - $($Pair.Label)" $extraSslvpn = Read-ANDConditionGroups -SideLabel "SSLVPN (additional)" -ADServer $ADServer -ADCredential $ADCredential foreach ($s in $extraSslvpn.SidSets) { $sslvpnSidSets.Add($s) } $sslvpnNames.AddRange([string[]]$extraSslvpn.RequiredNames) } } else { $addMoreAnswer = 'No' } $ipsecVsas = $null; $sslvpnVsas = $null; $baseName = $null; $insertAt = $null } } 'IpsecVsa' { Write-NPSHeader "VSA Group Names - $($Pair.Label)" Write-Host "Imported VSA group name: $($Pair.UserGroupValue)" -ForegroundColor Cyan if ($canGoBack) { $backCheck = Read-Host "Press Enter to continue, or B to go back" if ($backCheck -match '^[Bb](ack)?$') { $goBack = $true } } if (-not $goBack) { $newIpsecVsas = [System.Collections.Generic.List[string]]::new() if ($Pair.UserGroupValue) { $newIpsecVsas.Add($Pair.UserGroupValue) } $extraIpsecVsas = Get-VsaNamesForSide -SideLabel "IPSec" -RequiredNames @($ipsecNames | Select-Object -Unique) -ADServer $ADServer -ADCredential $ADCredential foreach ($v in $extraIpsecVsas) { if ($newIpsecVsas -notcontains $v) { $newIpsecVsas.Add($v) } } $ipsecVsas = @($newIpsecVsas) $baseName = $null; $insertAt = $null } } 'SslvpnVsa' { # Seed from THIS pair's own SSLVPN-side value when captured, not IPSec's - a client # needing different VSAs per side (confirmed real via actual client NPS exports) would # otherwise get the wrong VSA sent back for the SSLVPN side. $sslvpnSeedVsa = if ($Pair.UserGroupValueSSLVPN) { $Pair.UserGroupValueSSLVPN } else { $Pair.UserGroupValue } if ($canGoBack) { $backCheck = Read-Host "Press Enter to continue, or B to go back" if ($backCheck -match '^[Bb](ack)?$') { $goBack = $true } } if (-not $goBack) { $newSslvpnVsas = [System.Collections.Generic.List[string]]::new() if ($sslvpnSeedVsa) { $newSslvpnVsas.Add($sslvpnSeedVsa) } $extraSslvpnVsas = Get-VsaNamesForSide -SideLabel "SSLVPN" -RequiredNames @($sslvpnNames | Select-Object -Unique) -ADServer $ADServer -ADCredential $ADCredential foreach ($v in $extraSslvpnVsas) { if ($newSslvpnVsas -notcontains $v) { $newSslvpnVsas.Add($v) } } $sslvpnVsas = @($newSslvpnVsas) $baseName = $null; $insertAt = $null } } 'BaseName' { $defaultBaseName = "RADIUS - $($Pair.Label)" $baseNameResp = Read-Host "Base name for this policy [Enter for '$defaultBaseName'$(if ($canGoBack) { ', or B to go back' })]" if ($canGoBack -and $baseNameResp -match '^[Bb](ack)?$') { $goBack = $true } else { $baseName = if ([string]::IsNullOrWhiteSpace($baseNameResp)) { $defaultBaseName } else { $baseNameResp } $insertAt = $null } } 'Sequence' { # Re-read fresh EVERY time this step is reached (first pass or after a back-up) - a # PRIOR pair's own Add-NPSPolicySet/Add-NPSSingleRule call writes to $IASConfigPath and # shifts existing sequence numbers, so this listing/insert-at prompt always needs to # reflect current on-disk state, not stale data. # # Shows Name alongside Sequence (2026-08-31 per the maintainer: "can we show the list of # policy names so we can pick the right one, similar to how it's shown on the first # page of NPS Manager") - a bare list of numbers gave no way to tell WHICH existing # policy sits at any given sequence before picking where to insert. Same rendering # NPS-Manager.ps1's own "Current Network Policies" status view already uses (Option # 1's dashboard), reused here rather than a third independently-drifting copy. $policySummary = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType NetworkPolicy if ($policySummary.Count -eq 0) { Write-Host "No existing Network Policies yet - this will be the first." -ForegroundColor Gray } else { Write-Host "Current Network Policies (evaluated top to bottom, first match wins):" -ForegroundColor Cyan foreach ($p in $policySummary) { $stateTag = if ($p.Enabled) { '' } else { ' [DISABLED]' } Write-Host (" {0,3}. {1}{2}" -f $p.Sequence, $p.Name, $stateTag) } } $seqInput = Read-Host "Insert at sequence number [Enter for 1 = top priority$(if ($canGoBack) { ', or B to go back' })]" if ($canGoBack -and $seqInput -match '^[Bb](ack)?$') { $goBack = $true } else { $insertAt = if ([string]::IsNullOrWhiteSpace($seqInput)) { 1 } else { [int]$seqInput } } } } if ($goBack) { $stepIdx -= 1 # Step back OVER 'SslvpnVsa' if it doesn't apply to this pair (landing there would show a # step that can't actually be answered for a non-triplicate pair). if ($stepIdx -ge 0 -and $stepNames[$stepIdx] -eq 'SslvpnVsa' -and -not ($isTriplicateKnown -and $isTriplicate)) { $stepIdx -= 1 } } else { $stepIdx += 1 if ($stepIdx -lt $stepNames.Count -and $stepNames[$stepIdx] -eq 'SslvpnVsa' -and -not ($isTriplicateKnown -and $isTriplicate)) { $stepIdx += 1 } } } # --- Preview + commit - unchanged from the original inline loop, just reading from the step # sequence's own variables instead of one straight-line collection. --- if ($isTriplicate) { $preview = Add-NPSPolicySet -Path $IASConfigPath -BaseName $baseName -ClientIPAddress $ClientIPAddress ` -IPSecGroupSidSets $ipsecSidSets.ToArray() -SSLVPNGroupSidSets $sslvpnSidSets.ToArray() ` -IPSecVsaGroupNames @($ipsecVsas) -SSLVPNVsaGroupNames @($sslvpnVsas) ` -InsertAtSequence $insertAt -WhatIf Write-NPSHeader "Preview - $($Pair.Label)" Write-Host "Would create 3 policies starting at sequence $insertAt (Combined / IPSec-only / SSLVPN-only)." -ForegroundColor Cyan Write-Host " IPSec groups: $($ipsecNames -join ', ')" Write-Host " SSLVPN groups: $($sslvpnNames -join ', ')" Write-Host " IPSec VSAs: $($ipsecVsas -join ', ')" Write-Host " SSLVPN VSAs: $($sslvpnVsas -join ', ')" if ($preview.ShiftedSequences) { Write-Host "Existing policies at/after $insertAt will shift back by 3." -ForegroundColor Yellow } $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)" if ($confirm -notmatch '^[Yy]') { Write-Host "Skipped '$($Pair.Label)' - nothing changed for this pair." -ForegroundColor Gray return [pscustomobject]@{ Completed = $false; ADServer = $ADServer; ADCredential = $ADCredential } } $result = Add-NPSPolicySet -Path $IASConfigPath -BaseName $baseName -ClientIPAddress $ClientIPAddress ` -IPSecGroupSidSets $ipsecSidSets.ToArray() -SSLVPNGroupSidSets $sslvpnSidSets.ToArray() ` -IPSecVsaGroupNames @($ipsecVsas) -SSLVPNVsaGroupNames @($sslvpnVsas) ` -InsertAtSequence $insertAt Write-Host "Done. Backup: $($result.BackupPath)" -ForegroundColor Green } else { $preview = Add-NPSSingleRule -Path $IASConfigPath -DisplayName $baseName -ClientIPAddress $ClientIPAddress ` -GroupSidSets $ipsecSidSets.ToArray() -VsaGroupNames @($ipsecVsas) -InsertAtSequence $insertAt -WhatIf Write-NPSHeader "Preview - $($Pair.Label)" Write-Host "Would create 1 policy at sequence $insertAt`: $baseName" -ForegroundColor Cyan Write-Host " Groups: $($ipsecNames -join ', ')" Write-Host " VSAs: $($ipsecVsas -join ', ')" if ($preview.ShiftedSequences) { Write-Host "Existing policies at/after $insertAt will shift back by 1." -ForegroundColor Yellow } $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)" if ($confirm -notmatch '^[Yy]') { Write-Host "Skipped '$($Pair.Label)' - nothing changed for this pair." -ForegroundColor Gray return [pscustomobject]@{ Completed = $false; ADServer = $ADServer; ADCredential = $ADCredential } } $result = Add-NPSSingleRule -Path $IASConfigPath -DisplayName $baseName -ClientIPAddress $ClientIPAddress ` -GroupSidSets $ipsecSidSets.ToArray() -VsaGroupNames @($ipsecVsas) -InsertAtSequence $insertAt Write-Host "Done. Backup: $($result.BackupPath)" -ForegroundColor Green } return [pscustomobject]@{ Completed = $true; ADServer = $ADServer; ADCredential = $ADCredential } } # --------------------------------------------------------------------------- function Invoke-ImportKickstartDefinitions { <# .SYNOPSIS The full interactive Option 3 flow - pick an imported client, create/update a Shared Secret Template + RADIUS Client Template from its data, provision the live RADIUS Client FROM those templates, then optionally roll straight into a policy-set creation pre-seeded with the imported group/VSA (reusing Read-ANDConditionGroups/Get-VsaNamesForSide/Add-NPSPolicySet/ Add-NPSSingleRule exactly as Option 5 does, rather than a parallel implementation). #> param( [Parameter(Mandatory)][string]$IASConfigPath, [string]$AnswersPath, [string]$ADServer, [System.Management.Automation.PSCredential]$ADCredential, # Where to look for a staged NPSAnswers.json - MUST be NPS-Manager.ps1's own directory # ($BaseDir there), NOT this function's own $PSScriptRoot: PowerShell resolves $PSScriptRoot # lexically to the .ps1 FILE a function is DEFINED in (Modules\OrchestratorImport.ps1's own # folder) regardless of call site, so defaulting Get-NPSBakedInAnswers's lookup here would # silently search the wrong directory (Modules\, not where NPSAnswers.json actually gets # staged next to the dashboard script) - caught by testing this end-to-end, not by inspection. [string]$ScriptRoot = $PSScriptRoot, # Threaded through to Resolve-NPSGroupInteractive's AD fallback (Invoke-NPSADFallbackPrompt), # same as every other AD-touching interactive flow in this dashboard - lets a learned-good DC # get saved back into the staged shim for future launches instead of only helping THIS run. [string]$ShimPath ) Write-NPSHeader "Import Kickstart Definitions" # Pre-staged data wins if present (see Export-NPSOrchestratorAnswerFile/Get-NPSBakedInAnswers) - # this is what lets a physically-separated NPS Manager package (no network path back to # ClientAnswers) still import without the tech needing to hunt down a UNC path by hand. Its # absence is the normal case for a standalone/grab-and-run copy and just falls through to the # exact same live-folder-prompt flow this always had. $picked = Get-NPSBakedInAnswers -ScriptRoot $ScriptRoot if ($picked) { Write-Host "Using pre-staged answer data for '$($picked.CompanyName)' (NPSAnswers.json found next to this script)." -ForegroundColor Green } else { if ([string]::IsNullOrWhiteSpace($AnswersPath)) { $AnswersPath = Read-Host "Path to Master Orchestrator's 'IPSEC AIO\ClientAnswers' folder (UNC or local)" } if ([string]::IsNullOrWhiteSpace($AnswersPath)) { Write-Host "Cancelled." -ForegroundColor Gray; return } try { $clients = Get-NPSOrchestratorClients -AnswersPath $AnswersPath } catch { Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red Read-Host "Press Enter to continue" return } if ($clients.Count -eq 0) { Write-Host "No RADIUS-auth clients found under '$AnswersPath'." -ForegroundColor Yellow return } Write-Host "RADIUS-auth clients found:" -ForegroundColor Cyan for ($i = 0; $i -lt $clients.Count; $i++) { $c = $clients[$i] $flag = if ($c.IsComplete) { '' } else { ' [INCOMPLETE - placeholder/missing data]' } Write-Host (" {0}. {1}{2}" -f ($i + 1), $c.CompanyName, $flag) } $sel = Read-Host "Pick a client by number [Enter to cancel]" $idx = ($sel -as [int]) - 1 if ($idx -lt 0 -or $idx -ge $clients.Count) { Write-Host "Cancelled." -ForegroundColor Gray; return } $picked = $clients[$idx] } Write-Host "" Write-Host "Imported data for '$($picked.CompanyName)':" -ForegroundColor Cyan Write-Host " FortiGate Internal IP: $($picked.RADIUSFGTIntIP)" Write-Host " RADIUS Secret: (hidden - $($picked.RADIUSSecret.Length) chars)" Write-Host " Suggested Client Name: $($picked.RADIUSNPSFGTName)" # 2026-08-31 real bug, confirmed live against a real client's AD (the maintainer): AuthUserGroupName is the # FortiGate's own local group object name (e.g. "FGT_IKEv2_CorpLan_Users") - it's NEVER a real, # AD-resolvable group, purely a FortiGate-side identifier. AuthUserGroupValue IS the real AD group # (and, per the maintainer, the same value minus possible case - "IKEv2_CorpLan_Users" - doubling as the # VSA NPS sends back). Was previously shown backwards here (labeled "Required AD Group" but sourced # from AuthUserGroupName) - this whole function's own AD-resolution logic below had the identical # bug, now fixed alongside this display. Write-Host " Required AD Group / VSA: $($picked.AuthUserGroupValue)" Write-Host " FortiGate Group Name: $($picked.AuthUserGroupName)" if (-not $picked.IsComplete) { Write-Host "" Write-Host "WARNING: this client's data looks incomplete (placeholder IP, missing secret, or" -ForegroundColor Yellow Write-Host "missing group) - RADIUS may never have actually been set up for this client via the" -ForegroundColor Yellow Write-Host "CLI Builder yet." -ForegroundColor Yellow $proceedAnyway = Read-Host "Proceed anyway, filling gaps manually below? (Y/N)" if ($proceedAnyway -notmatch '^[Yy]') { Write-Host "Cancelled." -ForegroundColor Gray; return } } # --- Names/values shared by both steps below --- $clientName = Read-Host "Client display name [Enter for '$($picked.RADIUSNPSFGTName)']" if ([string]::IsNullOrWhiteSpace($clientName)) { $clientName = $picked.RADIUSNPSFGTName } $clientIP = Read-Host "FortiGate internal IP [Enter for '$($picked.RADIUSFGTIntIP)']" if ([string]::IsNullOrWhiteSpace($clientIP)) { $clientIP = $picked.RADIUSFGTIntIP } if ([string]::IsNullOrWhiteSpace($clientName) -or [string]::IsNullOrWhiteSpace($clientIP) -or $clientIP -match '[xX]') { Write-Host "ERROR: need a real client name and IP (no placeholders) to continue." -ForegroundColor Red Read-Host "Press Enter to continue" return } $secret = $picked.RADIUSSecret if ([string]::IsNullOrWhiteSpace($secret)) { $secret = Read-NPSSharedSecret -Prompt "No secret was imported - enter the Shared Secret for '$clientName'" } if ([string]::IsNullOrWhiteSpace($secret)) { Write-Host "ERROR: a shared secret is required." -ForegroundColor Red Read-Host "Press Enter to continue" return } # --- Step 1: Templates - created/updated FIRST, so the imported config is reusable later (a # second client/server for the same company) without re-typing anything, matching Option 4's # "Manage Templates" -> "Add a Client from a template" flow. See module header for the naming # convention ("<ClientName>-Secret" / "<ClientName>") and why this order was chosen. --- Write-NPSHeader "Step 1: Templates" $templatesPath = Get-NPSTemplatesPathFor -IASConfigPath $IASConfigPath $sstName = "$clientName-Secret" $rctName = $clientName $existingSst = Get-NPSSharedSecretTemplates -Path $templatesPath | Where-Object Name -eq $sstName if ($existingSst) { Write-Host "Shared Secret Template '$sstName' already exists." -ForegroundColor Yellow $updateSst = Read-Host "Update its value to the imported secret? (cascades to anything already linked to it) (Y/N)" if ($updateSst -match '^[Yy]') { $sstResult = Set-NPSSharedSecretTemplateValue -TemplateName $sstName -NewSecret $secret -Path $templatesPath -IASConfigPath $IASConfigPath if ($sstResult.Changed) { Write-Host "Updated. Backup: $($sstResult.BackupPath)" -ForegroundColor Green if ($sstResult.CascadedClientTemplateNames -contains $rctName) { Write-Host "'$rctName' (RADIUS Client Template) received the new value via that cascade." -ForegroundColor Green } } else { Write-Host "Already up to date." -ForegroundColor Gray } } } else { try { $sstResult = New-NPSSharedSecretTemplate -Path $templatesPath -Name $sstName -SharedSecret $secret Write-Host "Created Shared Secret Template '$sstName'." -ForegroundColor Green Write-Host "Backup: $($sstResult.BackupPath)" -ForegroundColor Green } catch { Write-Host "ERROR creating Shared Secret Template: $($_.Exception.Message)" -ForegroundColor Red Read-Host "Press Enter to continue" return } } $existingRct = Get-NPSClientTemplates -Path $templatesPath | Where-Object Name -eq $rctName if ($existingRct) { Write-Host "RADIUS Client Template '$rctName' already exists (IP $($existingRct.IPAddress))." -ForegroundColor Yellow # IP only here - NOT SharedSecret: a direct secret edit would decouple it from '$sstName' # (Set-NPSClientTemplateAttribute's own established behavior), which is exactly wrong here - # if it's already linked, the Set-NPSSharedSecretTemplateValue cascade above already delivered # the new secret; re-editing it directly would just needlessly sever that link. $updateRctIp = Read-Host "Update its IP to '$clientIP'? (Y/N)" if ($updateRctIp -match '^[Yy]') { $ipResult = Set-NPSClientTemplateAttribute -Path $templatesPath -Name $rctName -Attribute IPAddress -Value $clientIP if ($ipResult.Changed) { Write-Host "IP updated. Backup: $($ipResult.BackupPath)" -ForegroundColor Green } else { Write-Host "IP was already up to date." -ForegroundColor Gray } } } else { try { $rctResult = New-NPSClientTemplate -Path $templatesPath -Name $rctName -IPAddress $clientIP -SharedSecret $secret -SharedSecretTemplateName $sstName -TemplatesPath $templatesPath Write-Host "Created RADIUS Client Template '$rctName' (linked to '$sstName')." -ForegroundColor Green Write-Host "Backup: $($rctResult.BackupPath)" -ForegroundColor Green } catch { Write-Host "ERROR creating RADIUS Client Template: $($_.Exception.Message)" -ForegroundColor Red Read-Host "Press Enter to continue" return } } # --- Step 2: RADIUS Client - provisioned FROM the template above (Add-NPSClientFromTemplate), # not created directly - see module header. --- Write-NPSHeader "Step 2: RADIUS Client" $existingClients = Get-NPSClients -Path $IASConfigPath $existing = $existingClients | Where-Object Name -eq $clientName if ($existing) { Write-Host "A RADIUS client named '$clientName' already exists (IP $($existing.IPAddress))." -ForegroundColor Yellow $updateExisting = Read-Host "Update its IP/Shared Secret to the imported values? (Y/N)" if ($updateExisting -match '^[Yy]') { $ipEdit = Set-NPSClientAttribute -Path $IASConfigPath -Name $clientName -Attribute IPAddress -Value $clientIP if ($ipEdit.Changed) { Write-Host "IP updated." -ForegroundColor Green } $secResult = Set-NPSClientAttribute -Path $IASConfigPath -Name $clientName -Attribute SharedSecret -Value $secret if ($secResult.Changed) { Write-Host "Secret updated. Backup: $($secResult.BackupPath)" -ForegroundColor Green if ($secResult.Decoupled) { Write-Host "Note: this broke its existing template link (direct edit)." -ForegroundColor Yellow } } else { Write-Host "Secret was already up to date." -ForegroundColor Gray } } } else { try { $addResult = Add-NPSClientFromTemplate -Path $IASConfigPath -ClientTemplateName $rctName -NewClientName $clientName -IPAddress $clientIP -TemplatesPath $templatesPath Write-Host "Created RADIUS client '$clientName' ($clientIP), provisioned from template '$rctName'." -ForegroundColor Green Write-Host "Backup: $($addResult.BackupPath)" -ForegroundColor Green } catch { Write-Host "ERROR creating client: $($_.Exception.Message)" -ForegroundColor Red Read-Host "Press Enter to continue" return } } # --- Step 3: Connection Request Policy (optional) - governs WHETHER/HOW NPS even processes a # request from this client's FortiGate at all, before Network Policy authorization ever applies - # a real, working CRP always turned out to need a paired Proxy_Profiles entry too # (Add-NPSConnectionRequestRule now creates one, see its own corrected notes) - Import used to # never touch CRPs at all, leaving a tech to build one by hand in the NPS console every single # time (confirmed live, 2026-08-18 - recognizable as a "MANUAL - ..." entry in a real client's own # ias.xml). Matches this MSP's existing "RADIUS from FortiGate" naming convention and # Client-IP-Address condition (the same shape both the real auto-built entry and the maintainer's manual # one already use) rather than inventing a new one - a tech who wants something different can # still rename/adjust via Option 5 afterward. Write-NPSHeader "Step 3: Connection Request Policy" $suggestedCrpName = "RADIUS from FortiGate" $existingCrps = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType ConnectionRequest $existingCrp = $existingCrps | Where-Object Name -eq $suggestedCrpName if ($existingCrp) { Write-Host "A Connection Request Policy named '$suggestedCrpName' already exists." -ForegroundColor Yellow Write-Host " Current condition(s): $($existingCrp.Constraints -join '; ')" -ForegroundColor Gray Write-Host "Leaving it as-is - use Option 5 (Manage NPS Rules) to review/adjust it if needed." -ForegroundColor Gray } else { $crpName = Read-Host "Connection Request Policy name [Enter for '$suggestedCrpName']" if ([string]::IsNullOrWhiteSpace($crpName)) { $crpName = $suggestedCrpName } $addCrp = Read-Host "Create Connection Request Policy '$crpName' matching Client-IP-Address=${clientIP}? (Y/N, default Y)" if ([string]::IsNullOrWhiteSpace($addCrp) -or $addCrp -match '^[Yy]') { try { $crpResult = Add-NPSConnectionRequestRule -Path $IASConfigPath -DisplayName $crpName -Conditions @("MATCH(`"Client-IP-Address=$clientIP`")") Write-Host "Created Connection Request Policy '$crpName'." -ForegroundColor Green Write-Host "Backup: $($crpResult.BackupPath)" -ForegroundColor Green } catch { Write-Host "ERROR creating Connection Request Policy: $($_.Exception.Message)" -ForegroundColor Red Write-Host "Skipping - use Option 5 to add it manually instead." -ForegroundColor Yellow Read-Host "Press Enter to continue" } } else { Write-Host "Skipped - use Option 5 later to add it manually if needed." -ForegroundColor Gray } } # --- Step 4: policy set(s) (optional) - picks and imports pairs one at a time from a list # (multi-group-pair support, per the maintainer 2026-08-14; restructured to a pair-PICKER 2026-08-31, see # Invoke-SingleNPSPairImport's own header). Pre-existing clients saved before that feature # existed have no RadiusGroupPairs array - a single pair is SYNTHESIZED from the older flat # AuthUserGroupName/Value/NameSSLVPN fields so this whole step still works EXACTLY as it always # did for them (a "list" of just one). --- Write-NPSHeader "Step 4: NPS Rule(s)" $addRules = Read-Host "Also create the NPS rule(s) for this client now? (Y/N)" if ($addRules -notmatch '^[Yy]') { Write-Host "Done - client only. Use Option 5 later to add rules." -ForegroundColor Green return } # IsTriplicate is $null on the synthesized pair (unknown ahead of time, same as this always # worked - asked interactively below with the old tunnel-name-based hint). A REAL captured pair # already has its own IsTriplicate decided at CLI Builder time - used as this pair's PRE-FILLED # default below instead, but still asked (so a tech can override) rather than trusted blindly. $pairsToProcess = if ($picked.RadiusGroupPairs -and @($picked.RadiusGroupPairs).Count -gt 0) { @($picked.RadiusGroupPairs) } else { @([pscustomobject]@{ Label = $picked.CompanyName UserGroupName = $picked.AuthUserGroupName UserGroupValue = $picked.AuthUserGroupValue UserGroupValueSSLVPN = $picked.AuthUserGroupValueSSLVPN IsTriplicate = $null }) } # UserGroupNameSSLVPN -> UserGroupValueSSLVPN rename (2026-08-20) - pairs pulled straight from an # already-saved RadiusGroupPairs array (the branch above) still carry the OLD property name as-is, # since that array is read verbatim off disk, not rebuilt through the synthesized-pair path below # it. Every downstream reference in this function uses the new name, so alias it onto each pair # here (PSCustomObjects from ConvertFrom-Json support adding a note property directly) rather than # requiring every real client's saved JSON to be edited first. foreach ($p in $pairsToProcess) { if ($p.PSObject.Properties['UserGroupNameSSLVPN'] -and -not $p.PSObject.Properties['UserGroupValueSSLVPN']) { $p | Add-Member -NotePropertyName 'UserGroupValueSSLVPN' -NotePropertyValue $p.UserGroupNameSSLVPN -Force } } # Pair PICKER (2026-08-31 per the maintainer: "could we do a listing of the RADIUS group pairs and allow # us to pick and choose which one(s) we want to do? When done importing it should drop back to # the overall list (empty if already imported)"). Replaces the old unconditional "foreach every # pair in order" batch loop - a tech can now import one pair, review the result, then come back # for the next, instead of being committed to the whole list front to back. "Already imported" # is tracked SESSION-LOCAL only (not detected from existing policy names on disk) - simple and # predictable; re-running Option 3 later shows the full list again, which is fine since # re-importing an already-existing policy just updates it (Add-NPSPolicySet/Add-NPSSingleRule are # both upsert-safe). $importedThisSession = [System.Collections.Generic.List[string]]::new() while ($true) { $remaining = @($pairsToProcess | Where-Object { $importedThisSession -notcontains $_.Label }) if ($remaining.Count -eq 0) { if ($importedThisSession.Count -gt 0) { Write-Host "All RADIUS group pairs imported this session." -ForegroundColor Green } else { Write-Host "No RADIUS group pairs to import." -ForegroundColor Yellow } break } Write-NPSHeader "Step 4: NPS Rule(s) - Pick a Pair" Write-Host "RADIUS group pairs for '$($picked.CompanyName)':" -ForegroundColor Cyan for ($i = 0; $i -lt $remaining.Count; $i++) { $rp = $remaining[$i] $tripTag = if ($null -eq $rp.IsTriplicate) { " (asks IKEv2/SSLVPN when imported)" } elseif ($rp.IsTriplicate) { " (IKEv2 + SSLVPN)" } else { " (IKEv2 only)" } Write-Host (" {0}. {1}{2}" -f ($i + 1), $rp.Label, $tripTag) } if ($importedThisSession.Count -gt 0) { Write-Host " Already imported this session: $($importedThisSession -join ', ')" -ForegroundColor DarkGray } Write-Host "" $pickResp = Read-Host "Enter a number to import that pair, A for all remaining, or Q to finish" if ($pickResp -match '^[Qq]$') { break } $toImport = if ($pickResp -match '^[Aa]$') { @($remaining) } else { $pickIdx = ($pickResp -as [int]) - 1 if ($pickIdx -lt 0 -or $pickIdx -ge $remaining.Count) { Write-Host "Invalid selection." -ForegroundColor Yellow Read-Host "Press Enter to continue" continue } @($remaining[$pickIdx]) } foreach ($pairToImport in $toImport) { $importResult = Invoke-SingleNPSPairImport -Pair $pairToImport -IASConfigPath $IASConfigPath -ClientIPAddress $clientIP ` -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath # AD server/credential fallbacks discovered mid-import (Invoke-NPSADFallbackPrompt) apply # for the rest of THIS session too - same "sticks for every AD call after this one" # behavior the old inline loop already had, just threaded back out through the return # value now that this lives in its own function. $ADServer = $importResult.ADServer $ADCredential = $importResult.ADCredential if ($importResult.Completed) { $importedThisSession.Add($pairToImport.Label) } } } } |