ForestManagement.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\ForestManagement.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName ForestManagement.Import.DoDotSource -Fallback $false if ($ForestManagement_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName ForestManagement.Import.IndividualFiles -Fallback $false if ($ForestManagement_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\preimport.ps1" # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\postimport.ps1" # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'ForestManagement' -Language 'en-US' function Assert-ADConnection { <# .SYNOPSIS Ensures connection to AD is possible before performing actions. .DESCRIPTION Ensures connection to AD is possible before performing actions. Should be the first things all commands connecting to AD should call. Do this before invoking callbacks, as the configuration change becomes pointless if the forest is unavailable to begin with, .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. Used to safely terminate the calling command in case of failure. .EXAMPLE PS C:\> Assert-ADConnection @parameters -Cmdlet $PSCmdlet Kills the calling command if AD is not available. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [Parameter(Mandatory = $true)] [System.Management.Automation.PSCmdlet] $Cmdlet ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { # A domain being unable to retrieve its own object can really only happen if the service is down try { $null = Get-ADDomain @parameters -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -String 'Assert-ADConnection.Failed' -StringValues $Server -Tag 'failed' -ErrorRecord $_ $Cmdlet.ThrowTerminatingError($_) } } } function Assert-Configuration { <# .SYNOPSIS Ensures a set of configuration settings has been provided for the specified setting type. .DESCRIPTION Ensures a set of configuration settings has been provided for the specified setting type. This maps to the configuration variables defined in variables.ps1 Note: Not ALL variables defined in that file should be mapped, only those storing individual configuration settings! .PARAMETER Type The setting type to assert. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command. Used to terminate said calling command if relevant settings are missing .EXAMPLE PS C:\> Assert-Configuration -Type Users Asserts, that users have already been specified. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [ValidateSet('Schema', 'SchemaLdif', 'SiteLinks', 'Sites', 'Subnets')] [string] $Type, [Parameter(Mandatory = $true)] [System.Management.Automation.PSCmdlet] $Cmdlet ) process { if ((Get-Variable -Name $Type -Scope Script -ValueOnly).Count -gt 0) { return } Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues $Type -FunctionName $Cmdlet.CommandRuntime $exception = New-Object System.Data.DataException("No configuration data provided for: $Type") $errorID = 'NotConfigured' $category = [System.Management.Automation.ErrorCategory]::NotSpecified $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, $Type) $cmdlet.ThrowTerminatingError($recordObject) } } function Compare-SchemaProperty { <# .SYNOPSIS Compares configuration vs. adobject of schema attributes. .DESCRIPTION Compares configuration vs. adobject of schema attributes. Designed for use when comparing schema attributes, for example in Test-FMSchemaLdif. Returns $true when the values are INEQUAL. .PARAMETER Setting The settings object containing the desired state for an attribute. .PARAMETER ADObject The ADObject of the attribute to compare. .PARAMETER PropertyName The property to compare. .PARAMETER RootDSE The RootDSE object connected to. Used for objectCategory comparisons. .PARAMETER Add Is satisfied with the defined items being part of the AD object property, without requiring an exact match between configuration and ad. .EXAMPLE PS C:\> Compare-SchemaProperty -Setting $setting -ADObject $adObject -PropertyName attributeSecurityGUID -RootDSE $rootDSE Returns, whether the values found in $setting and $adObject are different from each other. #> [OutputType([System.Boolean])] [CmdletBinding()] param ( [Parameter(Mandatory=$true)] $Setting, [Parameter(Mandatory=$true)] $ADObject, [Parameter(Mandatory=$true)] $PropertyName, [Parameter(Mandatory=$true)] $RootDSE, [switch] $Add ) switch ($PropertyName) { 'schemaIDGUID' { return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|')) } 'attributeSecurityGUID' { return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|')) } 'objectCategory' { return (($Setting.$PropertyName -replace '<SchemaContainerDN>',$RootDSE.schemaNamingContext) -ne ($ADObject.$PropertyName -join '|')) } 'DistinguishedName' { # Don't compare identifiers! return $false } 'Description' { # Prevent encoding errors / issues from falsifying the results if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } return (($Setting.$PropertyName -replace "[^\d\w]","_") -ne ($ADObject.$PropertyName -replace "[^\d\w]","_")) } 'mayContain' { if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=') } default { if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } if ($Add) { return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=') } return [bool](Compare-Object $Setting.$PropertyName $ADObject.$PropertyName) } } } function Compare-SiteLink { <# .SYNOPSIS Compares two sitelink objects. .DESCRIPTION Compares two sitelink objects. Returns the DifferenceSiteLink if it uses the same sites as the reference sitelink, no matter the order. .PARAMETER ReferenceSiteLink The sitelink to compare to input with. .PARAMETER DifferenceSiteLink The sitelink(s) to compare. .EXAMPLE $script:sitelinks.Values | Compare-SiteLink $refSiteLink Returns any registered sitelinks that span the same sites as $refSiteLink (Should never be more than 1!) #> [CmdletBinding()] Param ( [Parameter(Position = 0)] $ReferenceSiteLink, [Parameter(ValueFromPipeline = $true)] $DifferenceSiteLink ) process { foreach ($diffSiteLink in $DifferenceSiteLink) { if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site1) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site2)) { $diffSiteLink continue } if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site2) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site1)) { $diffSiteLink continue } } } } function ConvertTo-SchemaLdifPhase { <# .SYNOPSIS Converts ldif files into a phased state index. .DESCRIPTION Converts ldif files into a phased state index. For each phase/file for each object it calculates the resulting state after ALL commands in the file have been executed. This allows stepping through the individual ldif files in the order they are to be applied and figure out the last applied deployment state. .PARAMETER LdifData The set of Ldif file definitions as returned by Get-FMSchemaLdif .EXAMPLE PS C:\> $ldifPhases = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif) Returns the hashtable containing the different phases of all registered ldif files. #> [OutputType([Hashtable])] [CmdletBinding()] param ( $LdifData ) #region Utility Functions function Add-Node { [CmdletBinding()] param ( [string] $DistinguishedName, [string] $LdifName, [Hashtable] $MappingTable ) if (-not $MappingTable.ContainsKey($DistinguishedName)) { $MappingTable[$DistinguishedName] = @{ } } if (-not $MappingTable[$DistinguishedName][$LdifName]) { $MappingTable[$DistinguishedName][$LdifName] = @{ State = @{ } Add = @{ } Replace = @{ } } } } function Write-Change { [CmdletBinding()] param ( [string] $DistinguishedName, [string] $LdifName, $Change, [Hashtable] $MappingTable ) Add-Node -DistinguishedName $DistinguishedName -LdifName $LdifName -MappingTable $MappingTable $datasheet = $MappingTable[$DistinguishedName][$LdifName] switch -regex ($Change.changetype) { 'add' { $datasheet.State = @{ } foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'changeType', 'FM_OrderCount') { continue } $datasheet.State[$propertyName] = $Change.$propertyName } } 'modify' { #region We already have a defined state if ($datasheet.State.Count -gt 0) { if ($Change.add) { if ($datasheet.State.$($Change.add)) { $datasheet.State.$($Change.add) = @($datasheet.State.$($Change.add)) + @($Change.$($Change.add)) } else { $datasheet.State[$Change.add] = $Change.$($Change.add) } } elseif ($Change.replace) { $datasheet.State[$Change.replace] = $Change.$($Change.replace) } else { foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue } $datasheet.State[$propertyName] = $Change.$propertyName } } } #endregion We already have a defined state #region Undefined state else { if ($Change.add) { if ($datasheet.Add.$($Change.add)) { $datasheet.Add.$($Change.add) = @($datasheet.Add.$($Change.add)) + @($Change.$($Change.add)) } else { $datasheet.Add[$Change.add] = $Change.$($Change.add) } } elseif ($Change.replace) { $datasheet.Replace[$Change.replace] = $Change.$($Change.replace) } else { foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue } $datasheet.Replace[$propertyName] = $Change.$propertyName } } } #endregion Undefined state } } } function Copy-State { [CmdletBinding()] param ( [Hashtable] $MappingTable, [string] $OldLdif, [string] $NewLdif ) foreach ($name in $MappingTable.Keys) { Add-Node -DistinguishedName $name -LdifName $NewLdif -MappingTable $MappingTable foreach ($key in $MappingTable[$name][$OldLdif].State.Keys) { $MappingTable[$name][$NewLdif].State[$key] = $MappingTable[$name][$OldLdif].State[$key] | Write-Output } foreach ($key in $MappingTable[$name][$OldLdif].Add.Keys) { $MappingTable[$name][$NewLdif].Add[$key] = $MappingTable[$name][$OldLdif].Add[$key] | Write-Output } foreach ($key in $MappingTable[$name][$OldLdif].Replace.Keys) { $MappingTable[$name][$NewLdif].Replace[$key] = $MappingTable[$name][$OldLdif].Replace[$key] | Write-Output } } } function Remove-NoOp { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( $LdifData, [Hashtable] $MappingTable ) $identities = $MappingTable.Keys | Write-Output foreach ($identity in $identities) { foreach ($ldifFile in $LdifData) { if (-not $MappingTable[$identity][$ldifFile.Name]) { continue } if ($ldifFile.Settings.DistinguishedName -contains $identity) { continue } $MappingTable[$identity].Remove($ldifFile.Name) } } } #endregion Utility Functions $mappingTable = @{ } $sortedLdif = $ldifData | Sort-Object Weight $previousLdif = '' foreach ($ldifItem in $sortedLdif) { if ($previousLdif) { Copy-State -MappingTable $mappingTable -OldLdif $previousLdif -NewLdif $ldifItem.Name } foreach ($setting in ($ldifItem.Settings | Sort-Object FM_OrderCount)) { Write-Change -DistinguishedName $setting.DistinguishedName -LdifName $ldifItem.Name -Change $setting -MappingTable $mappingTable } $previousLdif = $ldifItem.Name } Remove-NoOp -LdifData $sortedLdif -MappingTable $mappingTable $mappingTable } function Get-SchemaAdminCredential { <# .SYNOPSIS Returns the credentials for the account to use for schema administration. .DESCRIPTION Returns the credentials for the account to use for schema administration. The behavior of this command is heavily controlled by the configuration system: ForestManagement.Schema.* .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Get-SchemaAdminCredential @parameters Returns the configured schema credentials #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] [OutputType([PSCredential])] [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $script:temporarySchemaUpdateUser = $null } process { #region Case: Explicit Credentials if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential') { Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential' return } #endregion Case: Explicit Credentials #region Case: Temporary Schema Admin Account if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.AutoCreate.TempAdmin') { do { $newName = "$(Get-Random -Minimum 100000 -Maximum 999999)_$($env:USERNAME)" } while (Get-ADUser @parameters -LDAPFilter "(name=$newName)") $password = New-Password -Length 128 -AsSecureString Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $newName -ScriptBlock { $newUser = New-ADUser @parameters -Name $newName -Description 'Temporary Admin account used to update the schema' -AccountPassword $password -PassThru -Enabled $true -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet if (-not $newUser) { return } $script:temporarySchemaUpdateUser = $newUser $domain = Get-ADDomain @parameters try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $newUser -ErrorAction Stop } catch { Remove-ADUser -Identity $userObject @parameters $script:temporarySchemaUpdateUser = $null Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Assignment.Failure' -StringValues $newName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($newName)", $password) return } #endregion Case: Temporary Schema Admin Account #region Case: Explicit Schema Admin Account if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name') { $accountName = Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name' if ($accountName -like "*\*") { $accountName = $account.Split("\")[1] } $domain = Get-ADDomain @parameters $accountObject = Get-ADUser @parameters -LDAPFilter "(name=$accountName)" $schemaAdmins = Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" -Properties Members #region Scenario: Account does not exist if (-not $accountObject) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoCreate')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.ExistsNot' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -Category ObjectNotFound } $password = New-Password -Length 128 -AsSecureString Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $accountName -ScriptBlock { $userObject = New-ADUser @parameters -Name $accountName -AccountPassword $password -Enabled $true -Description "Admin account for updating the schema. Created by $($env:USERDOMAIN)\$($env:USERNAME)" -PassThru -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet if (-not $userObject) { return } try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $userObject -ErrorAction Stop } catch { Remove-ADUser -Identity $userObject @parameters Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.GroupAssignment.Failure' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } #endregion Scenario: Account does not exist #region Fail Fast if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoGrant')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Unprivileged' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet } } if (-not $accountObject.Enabled) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoEnable')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Disabled' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet } } #endregion Fail Fast #region Prepare account for schema administration if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) { Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Group.Assignment' -Target $accountName -ScriptBlock { $null = $schemaAdmins | Add-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } if (-not $accountObject.Enabled) { Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Enable' -Target $accountName -ScriptBlock { $null = Enable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } #endregion Prepare account for schema administration #region Handle Password if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') { $password = New-Password -Length 128 -AsSecureString try { Write-PSFMessage -String 'Get-SchemaAdminCredential.Password.Reset' -StringValues $accountName $null = Set-ADAccountPassword @parameters -Identity $accountObject -NewPassword $password -ErrorAction Stop -Reset } catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.Reset.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } else { try { $password = Read-Host -Prompt "Specify password for schema admin $accountName" -AsSecureString -ErrorAction Stop } catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.InteractiveRead.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } #endregion Handle Password } #endregion Case: Explicit Schema Admin Account # Case: Current User Credential $Credential } } function Import-LdifFile { <# .SYNOPSIS Parses an LDIF file and returns the changes it applies. .DESCRIPTION Parses an LDIF file and returns the changes it applies. Note: schemaupdatenow commands are skipped. .PARAMETER Path The path to the LDIF file to parse. .EXAMPLE PS C:\> Import-LdifFile -Path $ldifFile Parses the ldif file and returns changes it applies. #> [CmdletBinding()] param ( [string] $Path ) begin { #region Utility Functions function Resolve-AttributeName { [OutputType([string])] [CmdletBinding()] param ( [string] $Name ) switch ($Name) { 'dn' { 'DistinguishedName' } default { $Name } } } function Resolve-AttributeValue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")] [CmdletBinding()] param ( [string] $Value, [bool] $IsBase64, [string] $AttributeName ) if ($IsBase64) { switch ($AttributeName) { 'schemaIDGUID' { [PSCustomObject]@{ Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value)) GuidData = [System.Convert]::FromBase64String($Value) } } 'attributeSecurityGUID' { [PSCustomObject]@{ Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value)) GuidData = [System.Convert]::FromBase64String($Value) } } 'omObjectClass' { [System.Convert]::FromBase64String($Value) } default { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) } } } else { if ($Value -eq "TRUE") { return $true } if ($Value -eq "FALSE") { return $false } if ($Value -eq "") { return '' } if ($null -ne ($Value -as [int])) { return ($Value -as [int]) } $Value } } #endregion Utility Functions $lines = Get-Content -Path $Path $currentObject = @{ } $lastKey = '' $orderCount = 0 } process { $isBase64 = $false foreach ($line in $lines) { if (-not $line) { continue } if ($line -like '#*') { continue } if ($line -like 'dn:*') { if (($currentObject.Keys.Count -gt 1) -and ($currentObject['replace'] -ne 'schemaupdatenow')) { [pscustomobject]$currentObject } $currentObject = @{ PSTypeName = 'ForestManagement.Schema.Ldif.Setting' DistinguishedName = ($line -replace '^dn:', '').Trim() -replace ',DC=X$' -replace ',CN=Schema,CN=Configuration$' FM_OrderCount = $orderCount } $orderCount++ $lastKey = 'DistinguishedName' continue } if ($line -match '^([^:]+):(?<colon>:*) (.*)$') { $isBase64 = $matches['colon'] -eq ':' $attributeName = Resolve-AttributeName -Name $matches[1] $attributeValue = Resolve-AttributeValue -Value $matches[2] -IsBase64 $isBase64 -AttributeName $attributeName # Prevent duplicate object classes - top is redundant and not listed in AD if (($attributeName -eq 'ObjectClass') -and ($attributeValue -eq 'Top')) { continue } if ($currentObject.ContainsKey($attributeName)) { $values = @($currentObject[$attributeName]) $values += $attributeValue $currentObject[$attributeName] = $values } else { $currentObject[$attributeName] = $attributeValue } $lastKey = $attributeName } # Handle value continuation on the next line # Values break line when exceeding a total width of 80 characters elseif ($line -match '^ (.+)$') { $currentObject[$lastKey] = $currentObject[$lastKey] + (Resolve-AttributeValue -Value $matches[1] -IsBase64 $isBase64 -AttributeName $lastKey) } } } end { # Process last item if ($currentObject.Keys.Count -gt 0) { if ($currentObject['replace'] -ne 'schemaupdatenow') { [pscustomobject]$currentObject } } } } function Invoke-Callback { <# .SYNOPSIS Invokes registered callbacks. .DESCRIPTION Invokes registered callbacks. Should be placed inside the begin block of every single Test-* and Invoke-* command. For more details on this system, call: Get-Help about_FM_callbacks .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER Cmdlet The $PSCmdlet variable of the calling command .EXAMPLE PS C:\> Invoke-Callback @parameters -Cmdlet $PSCmdlet Executes all callbacks against the specified server using the specified credentials. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [CmdletBinding()] Param ( [string] $Server, [PSCredential] $Credential, [Parameter(Mandatory = $true)] [System.Management.Automation.PSCmdlet] $Cmdlet ) begin { if (-not $script:callbacks) { return } if (-not $script:callbackDomains) { $script:callbackDomains = @{ } } if (-not $script:callbackForests) { $script:callbackForests = @{ } } $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $serverName = '<Default Domain>' if ($Server) { $serverName = $Server } } process { if (-not $script:callbacks) { return } if (-not $script:callbackDomains[$serverName]) { try { $script:callbackDomains[$serverName] = Get-ADDomain @parameters -ErrorAction Stop } catch { } # Ignore errors, might not work yet } if (-not $script:callbackForests[$serverName]) { try { $script:callbackForests[$serverName] = Get-ADForest @parameters -ErrorAction Stop } catch { } # Ignore errors, might not work yet } foreach ($callback in $script:callbacks.Values) { Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking' -StringValues $callback.Name try { $param = @($serverName, $Credential, $script:callbackDomains[$serverName], $script:callbackForests[$serverName]) $callback.Scriptblock.Invoke($param) Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Success' -StringValues $callback.Name } catch { Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Failed' -StringValues $callback.Name -ErrorRecord $_ $Cmdlet.ThrowTerminatingError($_) } } } } function Invoke-LdifFile { <# .SYNOPSIS Invokes a LDIF file against a target server / forest. .DESCRIPTION Invokes a LDIF file against a target server / forest. Note: This command assumes schema updates executed against the schema master (and will automatically switch to target that server). LDIF files are not technically constrained to performing schema updates however. Thus this function is not suitable to performing domain NC changes in a subdomain. .PARAMETER Path Path to the ldif file to import .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-LdifFile -Path .\schema.ldif Imports the schema.ldif file into the current forest's schema. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] param ( [Parameter(Mandatory = $true)] [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')] [string] $Path, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $parameters['Server'] = (Get-ADForest @parameters).SchemaMaster $domain = Get-ADDomain @parameters $arguments = @() if ($Credential) { $arguments += "-b" $networkCredential = $Credential.GetNetworkCredential() $arguments += $networkCredential.UserName $arguments += $networkCredential.Domain $arguments += $networkCredential.Password } # Load target server $arguments += '-s' $arguments += "$Server" # Other settings $arguments += '-i' # Import $arguments += '-k' # Ignore errors for items that already exist $arguments += '-c' $arguments += 'DC=X' $arguments += $domain.DistinguishedName # Load File $arguments += '-f' $arguments += (Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem) } process { Invoke-PSFProtectedCommand -ActionString 'Invoke-LdifFile.Invoking.File' -ActionStringValues $Path -ScriptBlock { $procInfo = Start-Process -FilePath ldifde.exe -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop -WindowStyle Hidden if ($procInfo.ExitCode) { $winError = [System.ComponentModel.Win32Exception]::new($procInfo.ExitCode) switch ($procInfo.ExitCode) { 8224 { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file. Validate domain health, especially FSMO assignment and replication health. $($winError.Message)", $winError) } default { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file: $($winError.Message)", $winError) } } throw $outerError } } -EnableException $true -Target $Server -PSCmdlet $PSCmdlet } } function New-Password { <# .SYNOPSIS Generate a new, complex password. .DESCRIPTION Generate a new, complex password. .PARAMETER Length The length of the password calculated. Defaults to 32 .PARAMETER AsSecureString Returns the password as secure string. .EXAMPLE PS C:\> New-Password Generates a new 32v character password. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] Param ( [int] $Length = 32, [switch] $AsSecureString ) begin { $characters = @{ 0 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z') 1 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z') 2 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9) 3 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@') 4 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z') 5 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z') 6 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9) 7 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@') } } process { $letters = foreach ($number in (1..$Length)) { $characters[(($number % 4) + (1..4 | Get-Random))] | Get-Random } if ($AsSecureString) { $letters -join "" | ConvertTo-SecureString -AsPlainText -Force } else { $letters -join "" } } } function Remove-SchemaAdminCredential { <# .SYNOPSIS Implements the post processing of schema admin credentials. .DESCRIPTION Implements the post processing of schema admin credentials. This command is responsible for applying the schema admin credential configuration policies. For example, it will remove temporary admin accounts or perform the auto-reset auf admin credentials. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER SchemaAccountCredential The credential object of the schema admin that was returned by Get-SchemaAdminCredential. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Remove-SchemaAdminCredential @removeParameters Cleans up the credentials according to policy. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [PSCredential] $SchemaAccountCredential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $domain = Get-ADDomain @parameters } process { if ($SchemaAccountCredential) { $userName = $SchemaAccountCredential.GetNetworkCredential().UserName try { Write-PSFMessage -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve' -StringValues $userName $accountObject = Get-ADUser @parameters -Identity $userName -ErrorAction Stop } catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve.Failed' -StringValues $userName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoRevoke') -and ($accountObject)) { Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.Group.Revoke' -Target $username -ScriptBlock { "$($domain.DomainSID)-518" | Remove-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDisable') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.Disable' -Target $username -ScriptBlock { Disable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.PasswordReset' -Target $username -ScriptBlock { $password = New-Password -Length 128 -AsSecureString Set-ADAccountPassword @parameters -Identity $accountObject -ErrorAction Stop -NewPassword $password -Reset -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.AutoDescription' -Target $username -ScriptBlock { Set-ADUser @parameters -Identity $accountObject -Description (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } if ($script:temporarySchemaUpdateUser) { try { Write-PSFMessage -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove' -StringValues $script:temporarySchemaUpdateUser.Name Remove-ADUser @parameters -Identity $script:temporarySchemaUpdateUser -ErrorAction Stop -Confirm:$false $script:temporarySchemaUpdateUser = $null } catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove.Failed' -StringValues $script:temporarySchemaUpdateUser.Name -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } } } } function Resolve-SchemaAttribute { <# .SYNOPSIS Combines configuration and adobject into an attributes hashtable. .DESCRIPTION Combines configuration and adobject into an attributes hashtable. This is a helper function that allows to simplify the code used to create and update schema attributes. .PARAMETER Configuration The configuration object containing the desired schema attribute name. .PARAMETER ADObject The ADObject - if present - containing the current schema attribute configuration. Specifying this will cause it to return a delta hashtable useful for updating attributes. .EXAMPLE PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration Returns the attributes hashtable for a new schema attribute. .EXAMPLE PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject Returns the attributes hashtable for attributes to update. #> [OutputType([hashtable])] [CmdletBinding()] param ( $Configuration, $ADObject ) process { #region Build out basic attribute hashtable $attributes = @{ adminDisplayName = $Configuration.AdminDisplayName lDAPDisplayName = $Configuration.LdapDisplayName attributeId = $Configuration.OID oMSyntax = $Configuration.OMSyntax attributeSyntax = $Configuration.AttributeSyntax isSingleValued = ($Configuration.SingleValued -as [bool]) adminDescription = $Configuration.AdminDescription searchflags = $Configuration.SearchFlags isMemberOfPartialAttributeSet = $Configuration.PartialAttributeSet showInAdvancedViewOnly = $Configuration.AdvancedView } #endregion Build out basic attribute hashtable #region If ADObject is present: Remove attributes that are already present $attributeNames = 'isSingleValued', 'searchflags', 'isMemberOfPartialAttributeSet', 'oMSyntax', 'attributeId', 'adminDescription', 'adminDisplayName', 'showInAdvancedViewOnly', 'lDAPDisplayName', 'attributeSyntax' if ($ADObject) { foreach ($attributeName in $attributeNames) { if ($ADobject.$attributeName -eq $attributes[$attributeName]) { $attributes.Remove($attributeName) } } } #endregion If ADObject is present: Remove attributes that are already present $attributes } } function Update-Schema { <# .SYNOPSIS Forces a schema update. .DESCRIPTION Forces a schema update. This allows immediately assigning new attributes in schema. Generally, it is recommended targeting the schema master dc. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Update-Schema -Server dc1.contoso.com Forces a schema update on dc1.contoso.com #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [string] $Server, [PSCredential] $Credential ) $path = "LDAP://RootDSE" if ($Server) { $path = "LDAP://$Server/RootDSE" } if ($Credential) { $rootDSE = [adsi]::new($path, $Credential.UserName, $Credential.GetNetworkCredential().Password) } else { $rootDSE = [adsi]::new($path) } $null = $rootDSE.put("schemaUpdateNow", 1) $null = $rootDSE.SetInfo() } function ConvertTo-SubnetMask { <# .SYNOPSIS Converts the size of a mask into the mask as IPAddress .DESCRIPTION Converts the size of a mask into the mask as IPAddress .PARAMETER MaskSize The size of the subnet. Valid between 1 and 32 .EXAMPLE PS C:\> ConvertTo-SubnetMask -MaskSize 30 Converts the size (30) into the mask as IPAddress #> [OutputType([IPAddress])] [CmdletBinding()] param ( [ValidateRange(1, 32)] [int] $MaskSize ) process { $binaryString = ("1") * $MaskSize + ("0") * (32 - $MaskSize) $bytes = foreach ($number in (0 .. 3)) { [convert]::ToByte($binaryString.SubString(($number * 8), 8), 2) } [IPAddress]::new($bytes) } } function Test-Subnet { <# .SYNOPSIS Tests whether a host fits into the specified subnet. .DESCRIPTION Tests whether a host fits into the specified subnet. .PARAMETER NetworkAddress The address of the subnet. .PARAMETER MaskAddress The subnet mask of the subnet. .PARAMETER MaskSize The size of the mask of the subnet. .PARAMETER HostAddress The address of the host to test .EXAMPLE PS C:\> Test-Subnet -NetworkAddress '192.168.2.0' -MaskSize 24 -HostAddress '192.168.20.255' Checks whether the address '192.168.20.255' is part of the subnet '192.168.2.0/24' #> [CmdletBinding()] Param ( [IPAddress] $NetworkAddress, [IPAddress] $MaskAddress, [int] $MaskSize, [IPAddress] $HostAddress ) process { if ($MaskSize) { $MaskAddress = ConvertTo-SubnetMask -MaskSize $MaskSize } $NetworkAddress.Address -eq ($MaskAddress.Address -band $HostAddress.Address) } } function Get-FMSchema { <# .SYNOPSIS Returns the list of registered Schema Extensions. .DESCRIPTION Returns the list of registered Schema Extensions. .PARAMETER Name Name to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-FMSchema Returns a list of all schema extensions #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { ($script:schema.Values | Where-Object AdminDisplayName -Like $Name) } } function Invoke-FMSchema { <# .SYNOPSIS Updates the schema to conform to the desired state. .DESCRIPTION Updates the schema to conform to the desired state. Can add new attributes and update existing ones. Use Register-FMSchema to define the desired state. Use the module's configuration settings to govern schema admin credentials. The configuration can be read with Get-PSFConfig and updated with Set-PSFConfig. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMSchema Updates the schema of the current forest according to the configured settings #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Schema -Cmdlet $PSCmdlet try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster $removeParameters = $parameters.Clone() #region Resolve Credentials $cred = $null Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock { [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1 if ($cred) { $parameters['Credential'] = $cred } } -EnableException $EnableException -PSCmdlet $PSCmdlet if (Test-PSFFunctionInterrupt) { return } #endregion Resolve Credentials $testResult = Test-FMSchema @parameters # Prepare parameters to use for when discarding the schema credentials if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred } } process { if (Test-PSFFunctionInterrupt) { return } :main foreach ($testItem in $testResult) { switch ($testItem.Type) { #region Create new Schema Attribute 'ConfigurationOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Creating.Attribute' -Target $testItem.Identity -ScriptBlock { New-ADObject @parameters -Type attributeSchema -Name $testItem.Configuration.AdminDisplayName -Path $rootDSE.schemaNamingContext -OtherAttributes (Resolve-SchemaAttribute -Configuration $testItem.Configuration) -ErrorAction Stop Update-Schema @parameters } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue foreach ($class in $testItem.Configuration.ObjectClass) { try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.Configuration.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue -RetryCount 10 } } #endregion Create new Schema Attribute #region Update Schema Attribute 'InEqual' { $resolvedAttributes = Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject if ($resolvedAttributes.Keys.Count -ge 1) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Updating.Attribute' -ActionStringValues ($resolvedAttributes.Keys -join ', ') -Target $testItem.Identity -ScriptBlock { $testItem.ADObject | Set-ADObject @parameters -Replace $resolvedAttributes -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } foreach ($class in $testItem.Configuration.ObjectClass) { try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop -Properties mayContain } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } if ($classObject.mayContain -notcontains $testItem.ADObject.LdapDisplayName) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } } } #endregion Update Schema Attribute } } } end { if (Test-PSFFunctionInterrupt) { return } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock { $null = Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } function Register-FMSchema { <# .SYNOPSIS Registers a schema extension attribute. .DESCRIPTION Registers a schema extension attribute. These registered attributes will be applied / updated as needed when running Invoke-FMSchema. Use Test-FMSchema to verify, whether a forest is properly configured. .PARAMETER ObjectClass The class to assign the new attribute to. .PARAMETER OID The unique OID of the attribute. .PARAMETER AdminDisplayName The displayname of the attribute as admins see it. .PARAMETER LdapDisplayName The name of the attribute as LDAP sees it. .PARAMETER OMSyntax The OM Syntax of the attribute .PARAMETER AttributeSyntax The syntax rules of the attribute. .PARAMETER SingleValued Whether the attribute is singlevalued. .PARAMETER AdminDescription The human friendly description of the attribute. .PARAMETER SearchFlags The search flags for the attribute. .PARAMETER PartialAttributeSet Whether the attribute is part of a partial attribute set. .PARAMETER AdvancedView Whether this attribute is only shown in advanced view. Use this to hide it from the default display, used to simplify display by hiding information not needed for regulaar daily tasks. .EXAMPLE PS C:\> Get-Content .\schema.json | ConvertFrom-Json | Write-Output | Register-FMSchema Registers all extension attributes in the json file as schema settings to apply when running Invoke-FMSchema. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $ObjectClass, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $OID, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AdminDisplayName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $LdapDisplayName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [int] $OMSyntax, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AttributeSyntax, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [switch] $SingleValued, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AdminDescription, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [int] $SearchFlags, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [bool] $PartialAttributeSet, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [bool] $AdvancedView ) process { $script:schema[$AdminDisplayName] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.Configuration' ObjectClass = $ObjectClass OID = $OID AdminDisplayName = $AdminDisplayName LdapDisplayName = $LdapDisplayName OMSyntax = $OMSyntax AttributeSyntax = $AttributeSyntax SingleValued = $SingleValued AdminDescription = $AdminDescription SearchFlags = $SearchFlags PartialAttributeSet = $PartialAttributeSet AdvancedView = $AdvancedView } } } function Test-FMSchema { <# .SYNOPSIS Compare the current schema with the configured / desired configuration state. .DESCRIPTION Compare the current schema with the configured / desired configuration state. Only compares the custom configured settings, ignores any changes outside. (So it's not a delta comparison to the AD baseline) .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .EXAMPLE PS C:\> Test-FMSchema Tests the current domain's schema configuration. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Schema -Cmdlet $PSCmdlet try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster } process { # Pick up termination flag from Stop-PSFFunction and interrupt if begin failed to connect if (Test-PSFFunctionInterrupt) { return } foreach ($schemaSetting in (Get-FMSchema)) { $schemaObject = $null $schemaObject = Get-ADObject @parameters -LDAPFilter "(name=$($schemaSetting.AdminDisplayName))" -SearchBase $rootDSE.schemaNamingContext -ErrorAction Ignore -Properties * if (-not $schemaObject) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'ConfigurationOnly' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = $null Server = $forest.SchemaMaster ADObject = $null Configuration = $schemaSetting } continue } $isEqual = $true $deltaProperties = @() if ($schemaSetting.LdapDisplayName -ne $schemaObject.lDAPDisplayName) { Write-PSFMessage -Level Warning -String 'Test-FMSchema.ReadOnly.Delta' -StringValues 'LdapDisplayName', $schemaObject.lDAPDisplayName, $schemaSetting.LdapDisplayName } if ($schemaSetting.OID -ne $schemaObject.attributeId) { Write-PSFMessage -Level Warning -String 'Test-FMSchema.ReadOnly.Delta' -StringValues 'OID/AttributeID', $schemaObject.lDAPDisplayName, $schemaSetting.OID } if ($schemaSetting.OMSyntax -ne $schemaObject.oMSyntax) { $isEqual = $false; $deltaProperties += 'OMSyntax' } if ($schemaSetting.AttributeSyntax -ne $schemaObject.attributeSyntax) { $isEqual = $false; $deltaProperties += 'AttributeSyntax' } if ($schemaSetting.SingleValued -ne $schemaObject.isSingleValued) { $isEqual = $false; $deltaProperties += 'SingleValued' } if ($schemaSetting.AdminDescription -ne $schemaObject.adminDescription) { $isEqual = $false; $deltaProperties += 'AdminDescription' } if ($schemaSetting.SearchFlags -ne $schemaObject.searchflags) { $isEqual = $false; $deltaProperties += 'SearchFlags' } if ($schemaSetting.PartialAttributeSet -ne $schemaObject.isMemberOfPartialAttributeSet) { $isEqual = $false; $deltaProperties += 'PartialAttributeSet' } if ($schemaSetting.AdvancedView -ne $schemaObject.showInAdvancedViewOnly) { $isEqual = $false; $deltaProperties += 'AdvancedView' } $mayContain = Get-ADObject @parameters -LDAPFilter "(mayContain=$($schemaSetting.LdapDisplayName))" -SearchBase $rootDSE.schemaNamingContext if (-not $mayContain -and $schemaSetting.ObjectClass) { $isEqual = $false $deltaProperties += 'ObjectClass' } elseif ($mayContain.Name | Compare-Object $schemaSetting.ObjectClass) { $isEqual = $false $deltaProperties += 'ObjectClass' } if (-not $isEqual) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'InEqual' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = $deltaProperties Server = $forest.SchemaMaster ADObject = $schemaObject Configuration = $schemaSetting } } } } } function Unregister-FMSchema { <# .SYNOPSIS Removes a configured schema extension. .DESCRIPTION Removes a configured schema extension. .PARAMETER Name Name(s) of the schema extensions to unregister. .EXAMPLE PS C:\> Unregister-FMSchema -Name $names Removes the list of names stored in $names from the registered schema extension configurations. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('AdminDisplayName')] [string[]] $Name ) process { foreach ($nameLabel in $Name) { $script:schema.Remove($nameLabel) } } } function Get-FMSchemaLdif { <# .SYNOPSIS Returns the registered schema ldif files. .DESCRIPTION Returns the registered schema ldif files. .PARAMETER Name The name to filter byy. .EXAMPLE PS C:\> Get-FMSchemaLdif List all registered ldif files. #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { ($script:schemaLdif.Values | Where-Object Name -Like $Name) } } function Invoke-FMSchemaLdif { <# .SYNOPSIS Applies missing LDIF files to a forest's schema. .DESCRIPTION Applies missing LDIF files to a forest's schema. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMSchemaLdif Tests the configured LDIF schema files and applies all still missing updates. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { #region Resolve Schema Master $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SchemaLdif -Cmdlet $PSCmdlet try { $forest = Get-ADForest @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $parameters["Server"] = $forest.SchemaMaster $removeParameters = $parameters.Clone() #endregion Resolve Schema Master #region Resolve Credentials $cred = $null Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock { [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1 if ($cred) { $parameters['Credential'] = $cred } } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet if (Test-PSFFunctionInterrupt) { return } #endregion Resolve Credentials # Prepare parameters to use for when discarding the schema credentials if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred } # Grab test results to get list of items to process $testResult = Test-FMSchemaLdif @parameters -EnableException:$EnableException } process { if (Test-PSFFunctionInterrupt) { return } foreach ($testItem in $testResult) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Invoke.File' -ActionStringValues $testItem.Identity -Target $forest.SchemaMaster -ScriptBlock { Invoke-LdifFile @parameters -Path $testItem.Configuration.Path -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } } end { if (Test-PSFFunctionInterrupt) { return } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock { Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } function Register-FMSchemaLdif { <# .SYNOPSIS Registers an ldif file for validation and application. .DESCRIPTION Registers an ldif file for validation and application. .PARAMETER Name The name to register the file under. .PARAMETER Path The path to the file to register. .PARAMETER Weight Ldif files will be applied in a certain order. The weight of an Ldif file determines, the order it is applied in. The lower the number, the earlier the file will be applied. Default: 50 .PARAMETER MissingObjectExemption Testing in a forest will cause it to complain about all objects the ldif file tries to modify, not create and doesn't exist. Using this parameter you can exempt individual classes from triggering this warning. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Register-FMSchemaLdif -Name Skype -Path "$PSScriptRoot\skype.ldif" Registers the Skype for Business schema extensions. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Name, [Parameter(Mandatory = $true)] [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')] [string] $Path, [int] $Weight = 50, [string[]] $MissingObjectExemption, [string] $ContextName = '<Undefined>' ) begin { $resolvedPath = Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem } process { $script:schemaLdif[$Name] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SchemaLdif.Configuration' Name = $Name Path = $resolvedPath Settings = (Import-LdifFile -Path $Path) MissingObjectExemption = ($MissingObjectExemption | ForEach-Object { $_ -replace '(^CN=)|(^)','CN=' }) Weight = $Weight ContextName = $ContextName } } } function Test-FMSchemaLdif { <# .SYNOPSIS Tests whether the configured ldif-file-based schema extension has been applied. .DESCRIPTION Tests whether the configured ldif-file-based schema extension has been applied. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .EXAMPLE PS C:\> Test-FMSchemaLdif Checks the current forest against all configured schema extension files #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SchemaLdif -Cmdlet $PSCmdlet try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop $forest = Get-ADForest @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $parameters["Server"] = $forest.SchemaMaster } process { $ldifMapping = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif) $ldifSorted = Get-FMSchemaLdif | Sort-Object Weight $changes = @{ } $missingEntities = @() foreach ($ldifFile in $ldifSorted) { $changes[$ldifFile.Name] = @() } foreach ($distinguishedName in $ldifMapping.Keys) { $hasDefinedState = $ldifMapping[$distinguishedName].Values.State.Count -gt 0 $attributeName = '{0},{1}' -f $distinguishedName, $rootDSE.schemaNamingContext #region Retrieve AD Object ($adObject) try { $adObject = Get-ADObject @parameters -Identity $attributeName -ErrorAction Stop -Properties * } catch { if ($hasDefinedState) { foreach ($file in $ldifMapping[$distinguishedName].Keys) { $changes[$file] += [PSCustomObject]@{ DN = $distinguishedName Property = '<FailsToExist>' File = $file Setting = $ldifMapping[$distinguishedName][$file] ADObject = $null ValueS = $null ValueA = $null } } } else { if ($distinguishedName -notin ($ldifSorted.MissingObjectExemption | Write-Output)) { Write-PSFMessage -Level Warning -String 'Test-FMSchemaLdif.Missing.SchemaItem' -StringValues $attributeName -Tag 'panic' $missingEntities += $attributeName } } continue } #endregion Retrieve AD Object ($adObject) #region Compare configured with real state ($offStateLdifName) $offStateLdif = foreach ($ldifFile in $ldifSorted) { # Skip files that do not yet contain the taret object if (-not $ldifMapping[$distinguishedName][$ldifFile.Name]) { continue } $definedState = $ldifMapping[$distinguishedName][$ldifFile.Name] if ($definedState.State.Count -gt 0) { foreach ($propertyName in $definedState.State.Keys) { if (Compare-SchemaProperty -Setting $definedState.State -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.State.$propertyName ValueA = $adObject.$propertyName } } } } else { foreach ($propertyName in $definedState.Add.Keys) { if (Compare-SchemaProperty -Setting $definedState.Add -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE -Add) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.Add.$propertyName ValueA = $adObject.$propertyName } } } foreach ($propertyName in $definedState.Replace.Keys) { if (Compare-SchemaProperty -Setting $definedState.Replace -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.Replace.$propertyName ValueA = $adObject.$propertyName } } } } } #endregion Compare configured with real state ($offStateLdifName) $applicableLdif = $ldifSorted | Where-Object Name -in $ldifMapping[$distinguishedName].Keys $lastAppliedItem = $applicableLdif | Where-Object Name -notin $offStateLdif.File | Sort-Object Weight -Descending | Select-Object -First 1 foreach ($ldifFile in $applicableLdif) { if ($ldifFile.Weight -lt $lastAppliedItem.Weight) { continue } if ($lastAppliedItem.Name -eq $ldifFile.Name) { continue } foreach ($entry in $offStateLdif) { if ($entry.File -ne $ldifFile.Name) { continue } $changes[$ldifFile.Name] += $entry } } } $ldifResult = foreach ($schemaName in $changes.Keys) { if (-not $changes[$schemaName]) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.SchemaLdif.TestResult' Type = 'InEqual' ObjectType = 'SchemaLdif' Identity = $schemaName Changed = $changes[$schemaName] Server = $forest.SchemaMaster DeltaCount = $changes[$schemaName].Count ADObject = $null Configuration = ($ldifSorted | Where-Object Name -eq $schemaName) } } $ldifResult | Sort-Object { $_.Configuration.Weight } } } function Unregister-FMSchemaLdif { <# .SYNOPSIS Removes a registered ldif file from the configured state. .DESCRIPTION Removes a registered ldif file from the configured state. .PARAMETER Name The name to select the ldif file by. .EXAMPLE PS C:\> Get-FMSchemaLdif | Unregister-FMSchemaLdif Unregisters all registered ldif files. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($nameLabel in $Name) { $script:schemaLdif.Remove($nameLabel) } } } function Invoke-FMServer { <# .SYNOPSIS Ensures domain controllers are assigned to sites suitable for their IP addresses. .DESCRIPTION Ensures domain controllers are assigned to sites suitable for their IP addresses. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMServer Ensures all domain controllers in the current forest are in the correct site. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential Assert-ADConnection @parameters -Cmdlet $PSCmdlet $testResult = Test-FMServer @parameters } process { foreach ($testItem in $testResult) { switch ($testItem.Type) { 'AddressNotFound' { if (-not $testItem.ADObject.DNSHostName) { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NotFound' -StringValues $testItem.Identity -Target $testItem.Identity } else { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.FailedToResolve' -StringValues $testItem.Identity -Target $testItem.Identity } } 'NoMatchingSubnet' { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NoSubnet' -StringValues $testItem.Identity, $testItem.ADObject.IPAddress -Target $testItem.Identity } 'BadSite' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMServer.Server.Moving' -ActionStringValues $testItem.SupposedSite -Target $testItem.Identity -ScriptBlock { Move-ADDirectoryServer @parameters -Identity $testItem.ADobject.DistinguishedName -Site $testItem.SupposedSite -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Test-FMServer { <# .SYNOPSIS Checks whether the Domain Controller in a forest are in the correct site. .DESCRIPTION Checks whether the Domain Controller in a forest are in the correct site. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMServer Tests, whethether all domain controllers in the current forest are up-to-date. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet $rootDSE = Get-ADRootDSE @parameters $searchBase = "CN=Sites,$($rootDSE.configurationNamingContext)" $domainControllers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $searchBase -Properties * | Select-Object *, IPAddress, @{ Name = 'SiteName' Expression = { $_.DistinguishedName -replace ".+,CN=(.+?),CN=Sites,CN=Configuration,DC=.+",'$1' } } foreach ($domainController in $domainControllers) { if ($domainController.DNSHostName) { $domainController.IPAddress = [IPAddress](Resolve-DnsName -Name $domainController.DNSHostName -ErrorAction Ignore -Debug:$false | Where-Object Type -eq A | Select-Object -First 1).IPAddress } } $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-PSFObject 'Name', @{ Name = "SiteName" Expression = { ($_.Site | Get-ADObject @parameters).Name } }, 'Name.Split("/")[0] AS IPBase TO IPAddress', 'Name.Split("/")[1].Split("´n")[0] AS MaskSize To Int', Mask, site | Where-Object Name -notlike "*CNF*" | Sort-Object MaskSize -Descending foreach ($subnet in $allSubnets) { $subnet.Mask = ConvertTo-SubnetMask -MaskSize $subnet.MaskSize } } process { :main foreach ($domainController in $domainControllers) { #region No IP Address if (-not $domainController.IPAddress) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Server.TestResult' Type = 'AddressNotFound' ObjectType = 'Server' Identity = $domainController.Name Changed = $null Server = $Server CurrentSite = $domainController.SiteName SupposedSite = $null FoundSubnet = $null ADObject = $domainController } continue } #endregion No IP Address #region Resolving Subnet $foundSubnet = $null foreach ($subnet in $allSubnets) { if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) { $foundSubnet = $subnet break } } if (-not $foundSubnet) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Server.TestResult' Type = 'NoMatchingSubnet' ObjectType = 'Server' Identity = $domainController.Name Changed = $null Server = $Server CurrentSite = $domainController.SiteName SupposedSite = $null FoundSubnet = $null ADObject = $domainController } continue } #endregion Resolving Subnet if ($domainController.SiteName -ne $foundSubnet.SiteName) { $currentSiteSubnets = $allSubnets | Where-Object SiteName -eq $domainController.SiteName foreach ($subnet in $currentSiteSubnets) { # Domain Controller is legally in his current site if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) { Write-PSFMessage -Level InternalComment -String 'Test-FMServer.SiteConflict' -StringValues $domainController.Name, $foundSubnet.SiteName, $domainController.SiteName, $foundSubnet.Name -Tag 'note' -Target $domainController.Name continue main } } [PSCustomObject]@{ PSTypeName = 'ForestManagement.Server.TestResult' Type = 'BadSite' ObjectType = 'Server' Identity = $domainController.Name Changed = $foundSubnet.SiteName Server = $Server CurrentSite = $domainController.SiteName SupposedSite = $foundSubnet.SiteName FoundSubnet = $foundSubnet ADObject = $domainController } } } } } function Get-FMSiteLink { <# .SYNOPSIS Returns the configured link between two sites. .DESCRIPTION Returns the configured link between two sites. .PARAMETER SiteName The site to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-FMSiteLink Returns all configured sitelinks. #> [CmdletBinding()] Param ( [string] $SiteName = "*" ) process { ($script:sitelinks.Values | Where-Object { ($_.Site1 -like $SiteName) -or ($_.Site2 -like $SiteName) }) } } function Invoke-FMSiteLink { <# .SYNOPSIS Update a forest's sitelink to conform to the defined configuration. .DESCRIPTION Update a forest's sitelink to conform to the defined configuration. Configuration is defined using Register-FMSiteLink. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMSiteLink Updates the current forest's sitelinks to conform to the defined configuration. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential Assert-ADConnection @parameters -Cmdlet $PSCmdlet $testResult = Test-FMSiteLink @parameters Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet } process { foreach ($testItem in $testResult) { switch ($testItem.Type) { #region Delete undesired Sitelink 'ForestOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Removing.SiteLink' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSiteLink @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Delete undesired Sitelink #region Create new Sitelink 'ConfigurationOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Creating.SiteLink' -Target $testItem.Name -ScriptBlock { $parametersCreate = $parameters.Clone() $parametersCreate += @{ ErrorAction = 'Stop' Name = $testItem.Name Description = $testItem.Description Cost = $testItem.Cost ReplicationFrequencyInMinutes = $testItem.ReplicationInterval SitesIncluded = $testItem.Site1, $testItem.Site2 } if ($testItem.Options) { $parametersCreate['OtherAttributes'] = @{ Options = $testItem.Options } } New-ADReplicationSiteLink @parametersCreate } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Create new Sitelink #region Update existing Sitelink 'InEqual' { if ($testItem.ADObject.Name -ne $testItem.IdealName) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Renaming.SiteLink' -ActionStringValues $testItem.IdealName -Target $testItem.Name -ScriptBlock { Rename-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -NewName $testItem.IdealName -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } $parametersUpdate = $parameters.Clone() $parametersUpdate += @{ ErrorAction = 'Stop' Identity = $testItem.ADObject.ObjectGUID } if ($testItem.Cost -ne $testItem.ADObject.Cost) { $parametersUpdate['Cost'] = $testItem.Cost } if ($testItem.Description -ne ([string]($testItem.ADObject.Description))) { $parametersUpdate['Description'] = $testItem.Description } if ($testItem.Options -ne ([int]($testItem.ADObject.Options))) { $parametersUpdate['Replace'] = @{ Options = $testItem.Options } } if ($testItem.ReplicationInterval -ne $testItem.ADObject.replInterval) { $parametersUpdate['ReplicationFrequencyInMinutes'] = $testItem.replInterval } # If the only change pending was the name, don't call a meaningles Set-ADReplicationSiteLink if ($parametersUpdate.Keys.Count -le (2 + $parameters.Keys.Count)) { continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Updating.SiteLink' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSiteLink @parametersUpdate } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Update existing Sitelink } } } } function Register-FMSiteLink { <# .SYNOPSIS Register a new sitelink configuration. .DESCRIPTION Register a new sitelink configuration. .PARAMETER Site1 The first sitename in the pair of sites to be linked. .PARAMETER Site2 The second sitename in the pair of sites to be linked. .PARAMETER Cost The cost of the connection between the two sites. .PARAMETER Interval The replication interval (in minutes) between two sites. Defaults to 15 minutes. Cannot be less than 15 minutes. .PARAMETER Description A description to add to the sitelink. For example, consider including a timestamp and the available bandwidth. .PARAMETER Option Any options for the sitelink. This is a bitmap with currently only one relevant setting: 00000001 : Change Notify (Changes replicate instantly, rather than the configured interval. Only use for high-bandwidth connections) .EXAMPLE PS C:\> Register-FMSiteLink -Site1 MySite -Site2 MyOtherSite -Cost 80 -Description '2019 | 1GB/s' -Option 1 Registers a new sitelink between MySite and MyOtherSite at a cost of 80, registering it as instant replication and adding docs on its bandwidth. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site1, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site2, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ValidateRange(1,[int]::MaxValue)] [int] $Cost, [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateRange(15,[int]::MaxValue)] [int] $Interval = 15, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Description, [Parameter(ValueFromPipelineByPropertyName = $true)] [int] $Option ) process { $sitelinkName = "{0}-{1}" -f $Site1, $Site2 $script:sitelinks[$sitelinkName] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.Configuration' Name = $sitelinkName Site1 = $Site1 Site2 = $Site2 Cost = $Cost Interval = $Interval Description = $Description Option = $Option } } } function Test-FMSiteLink { <# .SYNOPSIS Compares a live sitelink setup with the configured desired state. .DESCRIPTION Compares a live sitelink setup with the configured desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSiteLink Tests the current forest for compliance with the sitelink configuration #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet $allSiteLinks = Get-ADReplicationSiteLink @parameters -Filter * -Properties Cost,Description, Options, Name, replInterval, siteList | Select-Object * $linksToExclude = @() foreach ($siteLink in $allSiteLinks) { $count = 1 foreach ($site in $siteLink.siteList) { try { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value (Get-ADObject @parameters -Identity $site -Properties Name).Name } catch { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value $site } $count++ } #region More than 2 sites in Sitelink if ($siteLink.siteList.Count -ge 3) { if (Get-PSFConfigValue -FullName 'ForestManagement.SiteLink.MultilateralLinks') { Write-PSFMessage -Level Verbose -String 'Test-FMSiteLink.Information.MultipleSites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, multiple_sites -Target $siteLink.DistinguishedName [pscustomobject]@{ PSTypeName = 'ForestManagement.SiteLink.Information.MultipleSites' Type = 'SiteLink.MultipleSites' ObjectType = 'SiteLink' Identity = $siteLink.Name Changed = $null Server = $Server DistinguishedName = $siteLink.DistinguishedName Name = $siteLink.Name ADObject = $siteLink } $linksToExclude += $siteLink } else { Write-PSFMessage -Level Warning -String 'Test-FMSiteLink.Critical.TooManySites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, critical, panic -Target $siteLink.DistinguishedName [pscustomobject]@{ PSTypeName = 'ForestManagement.SiteLink.Critical.TooManySites' Type = 'SiteLink.TooManySites' ObjectType = 'SiteLink' Identity = $siteLink.Name Changed = $null Server = $Server DistinguishedName = $siteLink.DistinguishedName Name = $siteLink.Name ADObject = $siteLink } $linksToExclude += $siteLink } } #endregion More than 2 sites in Sitelink Add-Member -InputObject $siteLink -MemberType NoteProperty -Name IdealName -Value ('{0}-{1}' -f $siteLink.Site1, $siteLink.Site2) } $allSiteLinks = $allSiteLinks | Where-Object { $_ -notin $linksToExclude } } process { #region Test all sitelinks found in the forest foreach ($siteLink in $allSiteLinks) { if (-not (Get-FMSiteLink | Compare-SiteLink $siteLink)) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.TestResult' Type = 'ForestOnly' ObjectType = 'SiteLink' Identity = $siteLink.Name Changed = $null Server = $Server Name = $siteLink.Name Site1 = $siteLink.Site1 Site2 = $siteLink.Site2 IdealName = $siteLink.IdealName Cost = $siteLink.Cost Description = $siteLink.Description Options = $siteLink.Options ReplicationInterval = $siteLink.replInterval Configuration = $null ADObject = $siteLink } continue } $configuredSitelink = Get-FMSiteLink | Compare-SiteLink $siteLink | Select-Object -First 1 $isEqual = $true $deltaProperties = @() if ($configuredSiteLink.Name -ne $siteLink.Name) { $isEqual = $false; $deltaProperties += 'Name' } if ($configuredSiteLink.Cost -ne $siteLink.Cost) { $isEqual = $false; $deltaProperties += 'Cost' } if ($configuredSiteLink.Description -ne ([string]($siteLink.Description))) { $isEqual = $false; $deltaProperties += 'Description' } if ($configuredSiteLink.Option -ne ([int]($siteLink.Options))) { $isEqual = $false; $deltaProperties += 'Options' } if ($configuredSiteLink.Interval -ne $siteLink.replInterval) { $isEqual = $false; $deltaProperties += 'ReplicationInterval' } if (-not $isEqual) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.TestResult' Type = 'InEqual' ObjectType = 'SiteLink' Identity = $siteLink.Name Changed = $deltaProperties Server = $Server Name = $configuredSitelink.Name Site1 = $configuredSitelink.Site1 Site2 = $configuredSitelink.Site2 IdealName = $configuredSitelink.Name Cost = $configuredSitelink.Cost Description = $configuredSitelink.Description Options = $configuredSitelink.Option ReplicationInterval = $configuredSitelink.Interval Configuration = $configuredSitelink ADObject = $siteLink } } } #endregion Test all sitelinks found in the forest foreach ($configuredSitelink in (Get-FMSiteLink)) { if ($allSiteLinks | Compare-SiteLink $configuredSitelink) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.TestResult' Type = 'ConfigurationOnly' ObjectType = 'SiteLink' Identity = $configuredSitelink.Name Changed = $null Server = $Server Name = $configuredSitelink.Name Site1 = $configuredSitelink.Site1 Site2 = $configuredSitelink.Site2 IdealName = $configuredSitelink.Name Cost = $configuredSitelink.Cost Description = $configuredSitelink.Description Options = $configuredSitelink.Option ReplicationInterval = $configuredSitelink.Interval Configuration = $configuredSitelink ADObject = $null } } } } function Unregister-FMSiteLink { <# .SYNOPSIS Removes a link between two sites from configuration. .DESCRIPTION Removes a link between two sites from configuration. .PARAMETER Site1 The site1 of the link. .PARAMETER Site2 The site2 of the link. .EXAMPLE PS C:\> Unregister-FMSiteLink -Site1 MySite -Site2 MyOtherSite Removes a sitelink from configuration. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site1, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site2 ) process { $sitelinkName = "{0}-{1}" -f $Site1, $Site2 $sitelinkName2 = "{1}-{0}" -f $Site1, $Site2 $script:sitelinks.Remove($sitelinkName) $script:sitelinks.Remove($sitelinkName2) } } function Get-FMSite { <# .SYNOPSIS Returns the list of configured sites. .DESCRIPTION Returns the list of configured sites. Sites can be configured using Register-FMSite. Those configurations represent the "Should be" state as defined for the entire organization. .PARAMETER Name Name to filter by. Defaults to "*" .EXAMPLE PS C:\> Get-FMSite Returns all configured sites. #> [CmdletBinding()] Param ( [string] $Name = "*" ) process { ($script:sites.Values | Where-Object Name -like $Name) } } function Invoke-FMSite { <# .SYNOPSIS Adjusts the targeted forest to comply with the site configuration. .DESCRIPTION Adjusts the targeted forest to comply with the site configuration. Use Register-FMSiteConfiguration to register configuration settings. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMSite Scans the forest for discrepancies from the desired state Then attempts to rectify the state. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Sites -Cmdlet $PSCmdlet $testResult = Test-FMSite @parameters } process { foreach ($testItem in $testResult) { switch ($testItem.Type) { 'ForestOnly' { $siteObject = Get-ADReplicationSite @parameters -Identity $testItem.Name $servers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $siteObject.DistinguishedName if ($servers) { Write-PSFMessage -Level Warning -String 'Invoke-FMSite.Removing.Site.ChildServers' -StringValues ($servers.Name -join ", ") -Tag 'failed','sites' } else { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Removing.Site' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSite @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } 'ConfigurationOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Creating.Site' -Target $testItem.Name -ScriptBlock { New-ADReplicationSite @parameters -Name $testItem.Name -Description $testItem.Description -OtherAttributes @{ Location = $testItem.Location } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'InEqual' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Updating.Site' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSite @parameters -Identity $testItem.Name -Description $testItem.Description -Replace @{ Location = $testItem.Location } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'RenamePending' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Renaming.Site' -ActionStringValues $testItem.NewName -Target $testItem.Name -ScriptBlock { Get-ADReplicationSite @parameters -Identity $testItem.Name | Rename-ADObject @parameters -NewName $testItem.NewName } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Register-FMSite { <# .SYNOPSIS Register a new site configuration. .DESCRIPTION Register a new site configuration. This is the ideal / desired state for the site setup. Forests will be brought into this state by using Invoke-FMSite. .PARAMETER Name Name of the site to apply. .PARAMETER Description Description the site should have. .PARAMETER Location Location the site should be part of. .PARAMETER OldNames Previous names for this site. Forests that have a site still using one of these names will have those sites renamed. .EXAMPLE PS C:\> Register-FMSite -Name ABCDE -Description "Some Site" -Location 'Atlantis' Registers a new desired site. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Description, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Location, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $OldNames ) process { $hashtable = @{ PSTypeName = 'ForestManagement.Site.Configuration' Name = $Name Description = $Description Location = $Location } if ($OldNames) { $hashtable["OldNames"] = $OldNames } $script:sites[$Name] = [PSCustomObject]$hashtable } } function Test-FMSite { <# .SYNOPSIS Tests a target foret's site configuration with the desired state. .DESCRIPTION Tests a target foret's site configuration with the desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSite Checks whether the current forest is compliant with the desired site configuration. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Sites -Cmdlet $PSCmdlet $allSites = Get-ADReplicationSite @parameters -Filter * -Properties Location $renameMapping = @{} $script:sites.Values | Where-Object OldNames | ForEach-Object { foreach ($oldName in $_.OldNames) { $renameMapping[$oldName] = $_.Name } } } process { $foundSites = @{} foreach ($site in $allSites) { if ($renameMapping.Keys -contains $site.Name) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Site.TestResult' Type = 'RenamePending' ObjectType = 'Site' Identity = $site.Name Changed = 'Name' Server = $Server Name = $site.Name Description = $site.Description Location = $site.Location NewName = $renameMapping[$site.Name] ADObject = $site } } elseif ($script:sites.Keys -contains $site.Name) { $foundSites[$site.Name] = $site } else { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Site.TestResult' Type = 'ForestOnly' ObjectType = 'Site' Identity = $site.Name Changed = $null Server = $Server Name = $site.Name Description = $site.Description Location = $site.Location ADObject = $site } } } foreach ($site in $script:sites.Values) { if ($site.Name -notin $allSites.Name) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Site.TestResult' Type = 'ConfigurationOnly' ObjectType = 'Site' Identity = $site.Name Changed = $null Server = $Server Name = $site.Name Description = $site.Description Location = $site.Location ADObject = $null } } } foreach ($site in $foundSites.Values) { $isEqual = $true $deltaProperties = @() if ([string]($site.Location) -ne $script:sites[$site.Name].Location) { $isEqual = $false; $deltaProperties += 'Location' } if ([string]($site.Description) -ne $script:sites[$site.Name].Description) { $isEqual = $false; $deltaProperties += 'Description' } if ($isEqual) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.Site.TestResult' Type = 'InEqual' ObjectType = 'Site' Identity = $site.Name Changed = $deltaProperties Server = $Server Name = $site.Name Description = $script:sites[$site.Name].Description Location = $script:sites[$site.Name].Location ADObject = $site } } } } function Unregister-FMSite { <# .SYNOPSIS Removes a site from the list of registered sites. .DESCRIPTION Removes a site from the list of registered sites. .PARAMETER Name Name of the site to unregister .EXAMPLE PS C:\> Unregister-FMSite -Name "MySite" Removes the site "MySite" from the list of registered sites #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)] [string[]] $Name ) process { foreach ($nameItem in $Name) { $script:sites.Remove($nameItem) } } } function Get-FMSubnet { <# .SYNOPSIS Returns the list of configured subnets. .DESCRIPTION Returns the list of configured subnets. Subnets can be configured using Register-FMSubnet. Those configurations represent the "Should be" state as defined for the entire organization. .PARAMETER Name Name of the subnet to filter by. Defaults to "*" .EXAMPLE PS C:\> Get-FMSubnet Returns all configured subnets. #> [CmdletBinding()] Param ( [string] $Name = "*" ) process { ($script:subnets.Values | Where-Object Name -like $Name) } } function Invoke-FMSubnet { <# .SYNOPSIS Corrects the subnet configuration of a forest. .DESCRIPTION Corrects the subnet configuration of a forest. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Invoke-FMSubnet Corrects the subnet configuration of the current forest. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Subnets -Cmdlet $PSCmdlet $testResult = Test-FMSubnet @parameters | Sort-Object { switch ($_.Type) { 'ForestOnly' { 1 } 'InEqual' { 2 } default { 3 } } } } process { foreach ($testItem in $testResult) { switch ($testItem.Type) { 'ForestOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Deleting.Subnet' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSubnet @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'ConfigurationOnly' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Creating.Subnet' -Target $testItem.Name -ScriptBlock { New-ADReplicationSubnet @parameters -Name $testItem.Name -Site $testItem.SiteName -Description $testItem.Description -Location $testItem.Location -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'InEqual' { $parametersSetSplat = $parameters.Clone() $parametersSetSplat['Identity'] = $testItem.Identity if ($testItem.SiteName -ne $testItem.ADObject.SiteName) { $parametersSetSplat['Site'] = $testItem.SiteName } if ($testItem.Description -ne ([string]($testItem.ADObject.Description))) { $parametersSetSplat['Description'] = $testItem.Description } if ($testItem.Location -ne ([string]($testItem.ADObject.Location))) { $parametersSetSplat['Location'] = $testItem.Location } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Updating.Subnet' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSubnet @parametersSetSplat -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Register-FMSubnet { <# .SYNOPSIS Registers a new subnet assignment. .DESCRIPTION Registers a new subnet assignment. Subnets are assigned to sites. .PARAMETER SiteName Name of the site to which subnets are being assigned. .PARAMETER Name Subnet to assign. Must be a subnet in the following notation: <ipv4address>/<subnetsize> E.g.: 1.2.3.4/24 .PARAMETER Description Description to add to the subnet .PARAMETER Location Location, where the subnet is at. .EXAMPLE PS C:\> Register-FMSubnet -SiteName MySite -Name '1.2.3.4/32' Assigns the subnet '1.2.3.4/32' to the site 'MySite' #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $SiteName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidateScript('ForestManagement.Validate.Subnet', ErrorString = 'ForestManagement.Validate.Subnet.Failed')] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Description, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Location ) process { $hashtable = @{ PSTypeName = 'ForestManagement.Subnet.Configuration' SiteName = $SiteName Name = $Name Description = $Description Location = $Location } $script:subnets[$Name] = [PSCustomObject]$hashtable } } function Test-FMSubnet { <# .SYNOPSIS Compares a forest's Subnet configuration against its desired state. .DESCRIPTION Compares a forest's Subnet configuration against its desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSubnet Compares the current forest's Subnet configuration against its desired state. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Subnets -Cmdlet $PSCmdlet $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-Object *, @{ Name = "SiteName" Expression = { ($_.Site | Get-ADObject @parameters).Name } } } process { #region Test all Subnets found in the forest foreach ($subnetItem in $allSubnets) { if ($script:subnets.Keys -notcontains $subnetItem.Name) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Subnet.TestResult' Type = 'ForestOnly' ObjectType = 'Subnet' Identity = $subnetItem.Name Changed = $null Server = $Server SiteName = $subnetItem.SiteName Name = $subnetItem.Name Description = $subnetItem.Description Location = $subnetItem.Location ADObject = $subnetItem } continue } $configuredSubnet = $script:subnets[$subnetItem.Name] $isEqual = $true $deltaProperties = @() if ($subnetItem.SiteName -ne $configuredSubnet.SiteName) { $isEqual = $false; $deltaProperties += 'SiteName' } if ([string]($subnetItem.Description) -ne $configuredSubnet.Description) { $isEqual = $false; $deltaProperties += 'Description' } if ([string]($subnetItem.Location) -ne $configuredSubnet.Location) { $isEqual = $false; $deltaProperties += 'Location' } if (-not $isEqual) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Subnet.TestResult' Type = 'InEqual' ObjectType = 'Subnet' Identity = $subnetItem.Name Changed = $deltaProperties Server = $Server SiteName = $configuredSubnet.SiteName Name = $configuredSubnet.Name Description = $configuredSubnet.Description Location = $configuredSubnet.Location ADObject = $subnetItem } } } #endregion Test all Subnets found in the forest #region Catch subnets only in configuration but NOT in forest foreach ($configuredSubnet in $script:subnets.Values) { if ($allSubnets.Name -contains $configuredSubnet.Name) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.Subnet.TestResult' Type = 'ConfigurationOnly' ObjectType = 'Subnet' Identity = $configuredSubnet.Name Changed = $null Server = $Server SiteName = $configuredSubnet.SiteName Name = $configuredSubnet.Name Description = $configuredSubnet.Description Location = $configuredSubnet.Location ADObject = $null } } #endregion Catch subnets only in configuration but NOT in forest } } function Unregister-FMSubnet { <# .SYNOPSIS Removes a subnet mapping. .DESCRIPTION Removes a subnet mapping. .PARAMETER Name Name of the subnets to unregister .EXAMPLE PS C:\> Unregister-FMSubnet -Name "1.2.3.4/32" Removes the subnet "1.2.3.4/32" #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)] [string[]] $Name ) process { foreach ($nameItem in $SiteName) { $script:subnets.Remove($nameItem) } } } function Clear-FMConfiguration { <# .SYNOPSIS Clears the stored configuration data. .DESCRIPTION Clears the stored configuration data. .EXAMPLE PS C:\> Clear-FMConfiguration Clears the stored configuration data. #> [CmdletBinding()] Param ( ) process { # Site Configurations $script:sites = @{ } # Subnet Configurations $script:subnets = @{ } # Sitelink Configurations $script:sitelinks = @{ } # Schema Definition $script:schema = @{ } # Schema Definitions for external LDIF files $script:schemaLdif = @{ } } } function Get-FMCallback { <# .SYNOPSIS Returns the list of registered callbacks. .DESCRIPTION Returns the list of registered callbacks. For more details on this system, call: Get-Help about_FM_callbacks .PARAMETER Name The name of the callback. Supports wildcard filtering. .EXAMPLE PS C:\> Get-FMCallback Returns a list of all registered callbacks #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { $script:callbacks.Values | Where-Object Name -like $Name } } function Register-FMCallback { <# .SYNOPSIS Registers a scriptblock to be called when invoking any Test- or Invoke- command. .DESCRIPTION Registers a scriptblock to be called when invoking any Test- or Invoke- command. This enables extending the module and ensuring correct configuration loading. The scriptblock will receive four arguments: - The Server targeted (if any) - The credentials used to do the targeting (if any) - The Forest the two earlier pieces of information map to (if any) - The Domain the two earlier pieces of information map to (if any) Any and all of these pieces of information may be empty. Any exception in a callback scriptblock will block further execution! For more details on this system, call: Get-Help about_FM_callbacks .PARAMETER Name The name of the callback to register (multiple can be active at any given moment). .PARAMETER ScriptBlock The scriptblock containing the callback logic. .EXAMPLE PS C:\> Register-FMCallback -Name MyCompany -Scriptblock $scriptblock Registers the scriptblock stored in $scriptblock under the name 'MyCompany' #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ScriptBlock] $ScriptBlock ) begin { if (-not $script:callbacks) { $script:callbacks = @{ } } } process { $script:callbacks[$Name] = [PSCustomObject]@{ Name = $Name ScriptBlock = $ScriptBlock } } } function Unregister-FMCallback { <# .SYNOPSIS Removes a callback from the list of registered callbacks. .DESCRIPTION Removes a callback from the list of registered callbacks. For more details on this system, call: Get-Help about_FM_callbacks .PARAMETER Name The name of the callback to remove. .EXAMPLE PS C:\> Get-FMCallback | Unregister-FMCallback Unregisters all callback scriptblocks that have been registered. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($nameItem in $Name) { $script:callbacks.Remove($nameItem) } } } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'ForestManagement' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'ForestManagement' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'ForestManagement' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." # Sitelinks Set-PSFConfig -Module 'ForestManagement' -Name 'SiteLink.MultilateralLinks' -Value $false -Initialize -Validation 'bool' -Description 'Whether sitelinks should be allowed to contain more than two sites. Enabling this will suppress all error messages when finding those.' # Schema Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.AutoCreate.TempAdmin' -Value $false -Initialize -Validation 'bool' -Description 'Schema Updates require special privileges not usually granted. Enabling this setting will have the task automatically create a temporary schema admin account with the permissions to execute the planned updates.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Credential' -Value $null -Initialize -Validation credential -Description 'Credentials to use for performing schema updates' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Name' -Value '' -Initialize -Validation string -Description 'The name of the account to use' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDescription' -Value '' -Initialize -Validation string -Description 'The description for the account used. If specified, this is what the description will be updated to after successfully using the account.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoCreate' -Value $false -Initialize -Validation bool -Description 'Whether the account should be created automatically if it isn''t present' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoEnable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be enabled for use if disabled.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDisable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be disabled after use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoGrant' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be added to the schema admins group before use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoRevoke' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be removed from the schema admins group after use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Password.AutoReset' -Value $false -Initialize -Validation bool -Description 'Whether the password of the used account should be reset before & after use.' <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'ForestManagement.ScriptBlockName' -Scriptblock { } #> Set-PSFScriptblock -Name 'ForestManagement.Validate.Path.SingleFile' -Scriptblock { try { Resolve-PSFPath -Path $_ -Provider FileSystem -SingleItem return $true } catch { return $false } } Set-PSFScriptblock -Name 'ForestManagement.Validate.Subnet' -Scriptblock { if (-not $_.Contains("/")) { return $false } if (($_ -split "/").Count -gt 2) { return $false } $base, $range = $_ -split "/" $ipv4Pattern = '^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$' if ($base -notmatch $ipv4Pattern) { return $false } $rangeNumber = $range -as [int] if (-not $rangeNumber) { return $false } if ($rangeNumber -lt 1) { return $false } if ($rangeNumber -gt 32) { return $false } $true } <# # Example: Register-PSFTeppScriptblock -Name "ForestManagement.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> Register-PSFTeppScriptblock -Name 'ForestManagement.ForestName' -ScriptBlock { (Get-ADTrust -Filter *).Target } Register-PSFTeppScriptblock -Name "ForestManagement.Sites" -ScriptBlock { $module = Get-Module ForestManagement & $module { $script:sites.Keys } } Register-PSFTeppScriptblock -Name "ForestManagement.Site2New" -ScriptBlock { $module = Get-Module ForestManagement $sites = & $module { $script:sites.Keys } $sitelinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site1) { return $sites | Sort-Object -Unique } $results = foreach ($site in $sites) { if ($site -eq $fakeBoundParameter.Site1) { continue } if ($siteLinks | Where-Object { ($_.Site1 -eq $fakeBoundParameter.Site1) -and ($_.Site2 -eq $site) }) { continue } if ($siteLinks | Where-Object { ($_.Site2 -eq $fakeBoundParameter.Site1) -and ($_.Site1 -eq $site) }) { continue } $site } $results | Sort-Object -Unique } Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site1" -ScriptBlock { $module = Get-Module ForestManagement $siteLinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site2) { return $siteLinks.Site1 | Sort-Object -Unique } ($siteLinks | Where-Object Site2 -eq $fakeBoundParameter.Site2).Site1 | Sort-Object -Unique } Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site2" -ScriptBlock { $module = Get-Module ForestManagement $siteLinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site1) { return $siteLinks.Site2 | Sort-Object -Unique } ($siteLinks | Where-Object Site1 -eq $fakeBoundParameter.Site1).Site2 | Sort-Object -Unique } <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name ForestManagement.alcohol #> Register-PSFTeppArgumentCompleter -Command Get-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Unregister-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Get-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Get-FMSiteLink -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site1 -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site2 -Name 'ForestManagement.Site2New' Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site1 -Name "ForestManagement.Linked.Site1" Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site2 -Name "ForestManagement.Linked.Site2" Register-PSFTeppArgumentCompleter -Command Invoke-FMSchema -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMServer -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSite -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSchema -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMServer -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSite -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName' New-PSFLicense -Product 'ForestManagement' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-08-05") -Text @" Copyright (c) 2019 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ # Site Configurations $script:sites = @{ } # Subnet Configurations $script:subnets = @{ } # Sitelink Configurations $script:sitelinks = @{ } # Schema Definition $script:schema = @{ } # Schema Definitions for external LDIF files $script:schemaLdif = @{ } #endregion Load compiled code |