Krbtgt.psm1

#Requires -Module ActiveDirectory

$script:ModuleRoot = $PSScriptRoot
$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\krbtgt.psd1").ModuleVersion

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName krbtgt.Import.DoDotSource -Fallback $false
if ($krbtgt_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 krbtgt.Import.IndividualFiles -Fallback $false
if ($krbtgt_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
    foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1"))
    {
        . Import-ModuleFile -Path $path
    }
    
    # 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
    foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1"))
    {
        . Import-ModuleFile -Path $path
    }
    
    # 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 'Krbtgt' -Language 'en-US'

# Enable Feature Flag: Inherit Enable Exception
Set-PSFFeature -Name PSFramework.InheritEnableException -Value $true -ModuleName 'Krbtgt'

# Prepare Onetime cache for PDC Emulators
Register-PSFTaskEngineTask -Name 'krbtgt.pdccache' -ScriptBlock {
    Set-PSFTaskEngineCache -Module krbtgt -Name PDCs -Value ((Get-ADForest).Domains | Get-ADDomain).PDCEmulator
} -Once

# Prepare Onetime cache for DCs of any kind
Register-PSFTaskEngineTask -Name 'krbtgt.dccache' -ScriptBlock {
    $dcHash = @{ }
    $rodcHash = @{ }
    
    foreach ($domain in ((Get-ADForest).Domains | Get-ADDomain))
    {
        try
        {
            $dcHash[$domain.DNSRoot] = (Get-ADComputer -Server $domain.PDCEmulator -LDAPFilter '(primaryGroupID=516)').DNSHostName
            $rodcHash[$domain.DNSRoot] = (Get-ADComputer -Server $domain.PDCEmulator -LDAPFilter '(primaryGroupID=521)').DNSHostName
        }
        catch { }
    }
    
    Set-PSFTaskEngineCache -Module krbtgt -Name DCs -Value $dcHash
    Set-PSFTaskEngineCache -Module krbtgt -Name RODCs -Value $rodcHash
} -Once

# Enable PSFComputer to understand ADDomainController objects
Register-PSFParameterClassMapping -ParameterClass Computer -TypeName 'Microsoft.ActiveDirectory.Management.ADDomainController' -Properties HostName

function Get-ADIdentifierType
{
    <#
    .SYNOPSIS
        Returns the type of the identifier string offered.
     
    .DESCRIPTION
        Returns the type of the identifier string offered.
        Can differentiate between distinguished names, objectGuid or SID.
        Will not perform any network calls to validate results.
     
    .PARAMETER Name
        The name to resolve
     
    .EXAMPLE
        PS C:\> Get-ADIdentifierType -Name '92469e61-8005-4c6d-b17c-478118f66c20'
 
        Validates that the specified string is a GUID.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name
    )
    
    if ($Name -match '^(\{{0,1}([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}\}{0,1})$') { return 'Guid' }
    if ($Name -like "*=*") { return 'DN' }
    if ($Name -match '^S-1-5-21-\d{7}-\d{9}-\d{9}-\d+$') { return 'SID' }
    return "Unknown"
}

function Get-LdapObject
{
    <#
        .SYNOPSIS
            Use LDAP to search in Active Directory
 
        .DESCRIPTION
            Utilizes LDAP to perform swift and efficient LDAP Queries.
 
        .PARAMETER LdapFilter
            The search filter to use when searching for objects.
            Must be a valid LDAP filter.
 
        .PARAMETER Properties
            The properties to retrieve.
            Keep bandwidth in mind and only request what is needed.
 
        .PARAMETER SearchRoot
            The root path to search in.
            This generally expects either the distinguished name of the Organizational unit or the DNS name of the domain.
            Alternatively, any legal LDAP protocol address can be specified.
 
        .PARAMETER Configuration
            Rather than searching in a specified path, switch to the configuration naming context.
 
        .PARAMETER Raw
            Return the raw AD object without processing it for PowerShell convenience.
 
        .PARAMETER PageSize
            Rather than searching in a specified path, switch to the schema naming context.
 
        .PARAMETER MaxSize
            The maximum number of items to return.
 
        .PARAMETER SearchScope
            Whether to search all OUs beneath the target root, only directly beneath it or only the root itself.
 
        .PARAMETER Server
            The server to contact for this query.
 
        .PARAMETER Credential
            The credentials to use for authenticating this query.
 
        .EXAMPLE
            PS C:\> Get-LdapObject -LdapFilter '(PrimaryGroupID=516)'
             
            Searches for all objects with primary group ID 516 (hint: Domain Controllers).
    #>

    [Alias('ldap')]
    [CmdletBinding(DefaultParameterSetName = 'SearchRoot')]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $LdapFilter,
        
        [string[]]
        $Properties = "*",
        
        [Parameter(ParameterSetName = 'SearchRoot')]
        [string]
        $SearchRoot,
        
        [Parameter(ParameterSetName = 'Configuration')]
        [switch]
        $Configuration,
        
        [switch]
        $Raw,
        
        [ValidateRange(1, 1000)]
        [int]
        $PageSize = 1000,
        
        [int]
        $MaxSize,
        
        [System.DirectoryServices.SearchScope]
        $SearchScope = 'Subtree',
        
        [string]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $searcher = New-Object system.directoryservices.directorysearcher
        $searcher.PageSize = $PageSize
        $searcher.SearchScope = $SearchScope
        
        if ($MaxSize -gt 0)
        {
            $Searcher.SizeLimit = $MaxSize
        }
        
        if ($SearchRoot)
        {
            $searcher.SearchRoot = New-DirectoryEntry -Path $SearchRoot -Server $Server -Credential $Credential
        }
        else
        {
            $searcher.SearchRoot = New-DirectoryEntry -Server $Server -Credential $Credential
        }
        if ($Configuration)
        {
            $searcher.SearchRoot = New-DirectoryEntry -Path ("LDAP://CN=Configuration,{0}" -f $searcher.SearchRoot.distinguishedName[0]) -Server $Server -Credential $Credential
        }
        
        Write-PSFMessage -String Get-LdapObject.SearchRoot -StringValues $SearchScope, $searcher.SearchRoot.Path -Level Debug
        
        if (Test-PSFParameterBinding -ParameterName Credential)
        {
            $searcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry($searcher.SearchRoot.Path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
        }
        
        $searcher.Filter = $LdapFilter
        
        foreach ($property in $Properties)
        {
            $null = $searcher.PropertiesToLoad.Add($property)
        }
        
        Write-PSFMessage -String Get-LdapObject.Searchfilter -StringValues $LdapFilter -Level Debug
    }
    process
    {
        try
        {
            foreach ($ldapobject in $searcher.FindAll())
            {
                if ($Raw)
                {
                    $ldapobject
                    continue
                }
                $resultHash = @{ }
                foreach ($key in $ldapobject.Properties.Keys)
                {
                    # Write-Output verwandelt Arrays mit nur einem Wert in nicht-Array Objekt
                    $resultHash[$key] = $ldapobject.Properties[$key] | Write-Output
                }
                if ($resultHash.ContainsKey("ObjectClass")) { $resultHash["PSTypeName"] = $resultHash["ObjectClass"] }
                [pscustomobject]$resultHash
            }
        }
        catch
        {
            Stop-PSFFunction -String 'Get-LdapObject.SearchError' -ErrorRecord $_ -Cmdlet $PSCmdlet -EnableException $true
        }
    }
}

function Get-RODomainController
{
<#
    .SYNOPSIS
        Returns a list of Read Only Domain Controllers.
     
    .DESCRIPTION
        Returns a list of Read Only Domain Controllers.
        Includes replication and krbtgt account information.
     
    .PARAMETER Name
        A name filter to limit the selection range.
     
    .PARAMETER Server
        The server to retrieve the information from.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-RODomainController
     
        Returns information on all RODCs in the current domain.
#>

    [CmdletBinding()]
    param (
        [string]
        $Name = "*",
        
        [string]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $adParameter = ($PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential)
        $parameter = @{
            LdapFilter = "(&(primaryGroupID=521)(name=$Name))"
            Properties = 'msDS-KrbTgtLink'
        } + $adParameter
    }
    process
    {
        $rodcs = Get-ADComputer @parameter
        foreach ($rodc in $rodcs)
        {
            $domainDN = ($rodc.DistinguishedName -split "," | Where-Object { $_ -like "DC=*" }) -join ','
            $siteServerObjects = Get-ADObject @adParameter -LDAPFilter "(&(objectClass=server)(dnsHostName=$($rodc.DNSHostName)))" -SearchBase "CN=Sites,CN=Configuration,$($domainDN)"
            $replicationPartner = @()
            foreach ($siteServerObject in $siteServerObjects)
            {
                $fromServer = (Get-ADObject @adParameter -SearchBase $siteServerObject.DistinguishedName -LDAPFilter '(objectClass=nTDSConnection)' -Properties FromServer).FromServer
                $replicationPartner += (Get-ADObject @adParameter $fromServer.Split(",", 2)[1] -Properties dNSHostName).dNSHostName
            }
            
            [PSCustomObject]@{
                DistinguishedName = $rodc.DistinguishedName
                DNSHostName          = $rodc.DNSHostName
                Name              = $rodc.Name
                Enabled              = $rodc.Enabled
                ReplicationPartner = $replicationPartner
                KerberosAccount   = $rodc.'msDS-KrbTgtLink'
            }
        }
    }
}


function New-DirectoryEntry
{
    <#
        .SYNOPSIS
            Generates a new directoryy entry object.
         
        .DESCRIPTION
            Generates a new directoryy entry object.
         
        .PARAMETER Path
            The LDAP path to bind to.
         
        .PARAMETER Server
            The server to connect to.
         
        .PARAMETER Credential
            The credentials to use for the connection.
         
        .EXAMPLE
            PS C:\> New-DirectoryEntry
 
            Creates a directory entry in the default context.
 
        .EXAMPLE
            PS C:\> New-DirectoryEntry -Server dc1.contoso.com -Credential $cred
 
            Creates a directory entry in the default context of the target server.
            The connection is established to just that server using the specified credentials.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string]
        $Path,
        
        [AllowEmptyString()]
        [string]
        $Server,
        
        [PSCredential]
        [AllowNull()]
        $Credential
    )
    
    if (-not $Path) { $resolvedPath = '' }
    elseif ($Path -like "LDAP://*") { $resolvedPath = $Path }
    elseif ($Path -notlike "*=*") { $resolvedPath = "LDAP://DC={0}" -f ($Path -split "\." -join ",DC=") }
    else { $resolvedPath = "LDAP://$($Path)" }
    
    if ($Server -and ($resolvedPath -notlike "LDAP://$($Server)/*"))
    {
        $resolvedPath = ("LDAP://{0}/{1}" -f $Server, $resolvedPath.Replace("LDAP://", "")).Trim("/")
    }
    
    if (($null -eq $Credential) -or ($Credential -eq [PSCredential]::Empty))
    {
        if ($resolvedPath) { New-Object System.DirectoryServices.DirectoryEntry($resolvedPath) }
        else
        {
            $entry = New-Object System.DirectoryServices.DirectoryEntry
            New-Object System.DirectoryServices.DirectoryEntry(('LDAP://{0}' -f $entry.distinguishedName[0]))
        }
    }
    else
    {
        if ($resolvedPath) { New-Object System.DirectoryServices.DirectoryEntry($resolvedPath, $Credential.UserName, $Credential.GetNetworkCredential().Password) }
        else { New-Object System.DirectoryServices.DirectoryEntry(("LDAP://DC={0}" -f ($env:USERDNSDOMAIN -split "\." -join ",DC=")), $Credential.UserName, $Credential.GetNetworkCredential().Password) }
    }
}

function New-Password
{
<#
    .SYNOPSIS
        Generates a random password
     
    .DESCRIPTION
        Generates a random password.
        Is guaranteed to be complex.
     
    .PARAMETER Length
        The number of characters the password should have.
        Defaults to 26
 
    .PARAMETER AsSecureString
        Returns the password as a secure string.
     
    .EXAMPLE
        PS C:\> New-Password
     
        Generates a 26 characters password.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [int]
        $Length = 26,

        [switch]
        $AsSecureString
    )
    
    $lower = 97 .. 122
    $upper = 65 .. 90
    $special = '^', '~', '!', '@', '#', '$', '%', '^', '&', '*', '_', '+', '=', '`', '|', '\', '(', ')', '{', '}', '[', ']', ':', ';', '"', "'", '<', '>', ',', '.', '?', '/'
    
    $password = foreach ($number in (1 .. $Length))
    {
        switch ($number % 3)
        {
            0 { [char]($lower | Get-Random) }
            1 { [char]($upper | Get-Random) }
            2 { [char]($special | Get-Random) }
        }
    }
    
    if ($AsSecureString) { $password -join "" | ConvertTo-SecureString -AsPlainText -Force }
    else { $password -join "" }
}

function Reset-UserPassword
{
<#
    .SYNOPSIS
        Resets a user's password.
     
    .DESCRIPTION
        Resets a user's password.
     
    .PARAMETER Identity
        The user to reset.
     
    .PARAMETER Server
        The server to execute this against.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Password
        The password to apply.
        Defaults to a random password.
     
    .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:\> Reset-UserPassword -Identity 'krbtgt'
     
        Resets the password on the krbtgt account.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [string]
        $Server,
        
        [PSCredential]
        $Credential,

        [SecureString]
        $Password = (New-Password -AsSecureString),
        
        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = @{
            Identity = $Identity
            NewPassword = $Password
            ErrorAction = 'Stop'
        } + ($PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential)
    }
    process
    {
        try
        {
            Write-PSFMessage -String 'Reset-UserPassword.PerformingReset' -StringValues $Identity
            Set-ADAccountPassword @parameters
            Write-PSFMessage -String 'Reset-UserPassword.PerformingReset.Success' -StringValues $Identity
        }
        catch
        {
            Stop-PSFFunction -String 'Reset-UserPassword.FailedToReset' -StringValues $Identity -ErrorRecord $_ -Cmdlet $PSCmdlet
            return
        }
    }
}


function Sync-LdapObject
{
    <#
        .SYNOPSIS
            Performs single object replication of an object between two separate directory servers.
         
        .DESCRIPTION
            Performs single object replication of an object between two separate directory servers.
             
        .PARAMETER Object
            The object to replicate.
            Accepts valid system identifiers:
            - SID
            - ObjectGUID
            - DistinguishedName
         
        .PARAMETER Server
            The server from which to replicate.
         
        .PARAMETER Target
            The destination server to replicate to.
         
        .PARAMETER Credential
            The credentials to use for replication.
         
        .PARAMETER Configuration
            If the target object is stored in the configuration node, specifying this parameter is required.
         
        .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:\> Sync-LdapObject -Object '92469e61-8005-4c6d-b17c-478118f66c20' -Server dc1.contoso.com -Target dc2.contoso.com
 
            Synchronizes the object identified by the specified guid from dc1 to dc2.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $Object,
        
        [Parameter(Mandatory = $true, Position = 1)]
        [string]
        $Server,
        
        [Parameter(Mandatory = $true, Position = 2)]
        [string]
        $Target,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $Configuration
    )
    
    begin
    {
        #region Defaults
        $stopDefault = @{
            Target = $Object
            EnableException = $true
            Cmdlet = $PSCmdlet
        }
        #endregion Defaults
        
        #region Resolving target object
        $adParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        switch (Get-ADIdentifierType -Name $Object)
        {
            'SID' { $ldapFilter = "(objectSID=$($Object))" }
            'Guid'
            {
                $bytes = ([guid]$Object).ToByteArray()
                $segments = foreach ($byte in $bytes)
                {
                    "\{0}" -f ([convert]::ToString($byte, 16))
                }
                $ldapFilter = "(objectGuid=$($segments -join ''))"
            }
            'DN' { $ldapFilter = "(distinguishedName=$($Object))" }
        }
        Write-PSFMessage -String 'Sync-LdapObject.SyncObjectFilter' -StringValues $ldapFilter -Target $Object
        try { $resolvedObject = Get-LdapObject @adParameter -LdapFilter $ldapFilter -Properties ObjectGUID -Configuration:$Configuration -ErrorAction Stop }
        catch { Stop-PSFFunction @stopDefault -String 'Sync-LdapObject.ObjectAccessError' -StringValues $Object, $_ -ErrorRecord $_ -OverrideExceptionMessage }
        if (-not $resolvedObject)
        {
            Stop-PSFFunction @stopDefault -String 'Sync-LdapObject.ObjectNotFound' -StringValues $Object -OverrideExceptionMessage
        }
        $objectGUID = ([guid][byte[]]$resolvedObject.objectGUID).Guid
        #endregion Resolving target object
    }
    process
    {
        try { $dstRootDSE = New-DirectoryEntry -Path "LDAP://$($Target)/RootDSE" -Credential $Credential -ErrorAction Stop }
        catch { Stop-PSFFunction @stopDefault -String 'Sync-LdapObject.DestinationAccessError' -StringValues $Target, $_ -ErrorRecord $_ -OverrideExceptionMessage }
        try { $srcRootDSE = New-DirectoryEntry -Path "LDAP://$($Server)/RootDSE" -Credential $Credential }
        catch { Stop-PSFFunction @stopDefault -String 'Sync-LdapObject.SourceAccessError' -StringValues $Server, $_ -ErrorRecord $_ -OverrideExceptionMessage }
        
        $replicationCommand = '{0}:<GUID={1}>' -f $srcRootDSE.dsServiceName.ToString(), $objectGUID
        
        $null = $dstRootDSE.Put("replicateSingleObject", $replicationCommand)
        Invoke-PSFProtectedCommand -ActionString 'Sync-LdapObject.PerformingReplication' -ActionStringValues $Server, $Target -Target $Object -ScriptBlock {
            $null = $dstRootDSE.SetInfo()
        } -EnableException $EnableException -PSCmdlet $PSCmdlet -RetryCount 2 -RetryWait 1
    }
}

function Sync-LdapObjectParallel
{
<#
    .SYNOPSIS
        Start LDAP-based single-object replication in parallel.
     
    .DESCRIPTION
        Start LDAP-based single-object replication.
        All servers will start sync with the target DC in parallel.
     
        Use this to minimize replication latency for critical changes on a single object, such as the krbtgt account.
     
    .PARAMETER Object
        The distinguished name of the object to replicate.
     
    .PARAMETER Server
        The server(s) from which to trigger the replication.
     
    .PARAMETER Target
        The target DC to replicate with.
     
    .PARAMETER Throttle
        Up to how many replications should be triggered in parallel.
        Defaults to 8 times the CPU count (this action mostly consists on waiting for the network response).
     
    .PARAMETER Credential
        Credentials to use when connecting to the Server(s).
        No connecting credentials for the target server are necessary, as this is handled by the servers operated against.
 
    .PARAMETER Reverse
        Reverse the sync order.
        By default, each server is instructed to replicate with the target.
        By reversing this, the target is instructed to replicate with each server instead.
     
    .PARAMETER Configuration
        Whether the object being replicated is in the configuration partition.
     
    .EXAMPLE
        PS C:\> Sync-LdapObjectParallel -Object 'CN=krbtgt,CN=Users,DC=contoso,DC=com' -Server 'dc2.contoso.com','dc3.contoso.com' -Target 'dc1.contoso.com'
     
        Replicates the krbtgt account from dc1 to dc2 & dc3 in parallel.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]
        $Object,
        
        [Parameter(Mandatory = $true, Position = 1)]
        [string[]]
        $Server,
        
        [Parameter(Mandatory = $true, Position = 2)]
        [string]
        $Target,
        
        [int]
        $Throttle = ($env:NUMBER_OF_PROCESSORS * 8),
        
        [PSCredential]
        $Credential,

        [switch]
        $Reverse,
        
        [switch]
        $Configuration
    )
    
    begin
    {
        #region Scriptblock
        $scriptblock = {
            param (
                $Settings
            )
            & (Get-Module krbtgt) {
                try
                {
                    $null = Sync-LdapObject @Settings
                    [PSCustomObject]@{
                        ComputerName = $Settings.Server
                        Success         = $true
                        Object         = $Settings.Object
                        Message         = ""
                        ExitCode     = 0
                        Error         = $null
                    }
                }
                catch
                {
                    [PSCustomObject]@{
                        ComputerName = $Settings.Server
                        Success         = $false
                        Object         = $Settings.Object
                        Message         = "$_"
                        ExitCode     = 1
                        Error         = $_
                    }
                }
            }
        }
        #endregion Scriptblock
        
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Object, Target, Configuration, Credential
        if ($Reverse) { $parameters.Server = $Target }

        $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
        $initialSessionState.ImportPSModule(("{0}\PSFramework.psd1" -f (Get-Module PSFramework).ModuleBase))
        $initialSessionState.ImportPSModule(("{0}\krbtgt.psd1" -f (Get-Module krbtgt).ModuleBase))
        $pool = [RunspaceFactory]::CreateRunspacePool($initialSessionState)
        $null = $pool.SetMinRunspaces(1)
        $null = $pool.SetMaxRunspaces($Throttle)
        $pool.ApartmentState = "MTA"
        $pool.Open()
        $runspaces = @()
    }
    process
    {
        foreach ($serverName in $Server)
        {
            $tempParameters = $parameters.Clone()
            if ($Reverse) { $tempParameters.Target = $serverName }
            else { $tempParameters.Server = $serverName }
            $runspace = [PowerShell]::Create()
            $null = $runspace.AddScript($scriptBlock)
            $null = $runspace.AddArgument($tempParameters)
            $runspace.RunspacePool = $pool
            $runspaces += [PSCustomObject]@{ Pipe = $runspace; Status = $runspace.BeginInvoke() }
        }
        foreach ($runspace in $runspaces)
        {
            $runspace.Pipe.EndInvoke($runspace.Status)
            $runspace.Pipe.Dispose()
        }
    }
    end
    {
        $pool.Close()
        $pool.Dispose()
    }
}

function Get-KrbAccount
{
<#
    .SYNOPSIS
        Returns information on the Krbtgt Account.
     
    .DESCRIPTION
        Returns information on the Krbtgt Account.
        Includes information on the Kerberos ticket configuration.
        Tries to use the GroupPolicy module to figure out the Kerberos policy settings.
     
    .PARAMETER Server
        The domain controller to ask for the information.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Identity
        The account to target.
        Defaults to the krbtgt account, but can be used to apply to other accounts (eg: The krbtgt account for a RODC)
     
    .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:\> Get-KrbAccount
     
        Returns the krbtgt account information.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [pscredential]
        $Credential,
        
        [string]
        $Identity = 'krbtgt',
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Prepare Preliminaries
        $adParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameter = @{
            Identity   = $Identity
            Properties = 'PasswordLastSet'
        }
        $parameter += $adParameter
        
        try
        {
            $domainObject = Get-ADDomain @adParameter -ErrorAction Stop
        }
        catch
        {
            Stop-PSFFunction -String 'Get-KrbAccount.FailedDomainAccess' -ErrorRecord $_ -Cmdlet $PSCmdlet
            return
        }
        #endregion Prepare Preliminaries
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        #region Get basic account properties
        Write-PSFMessage -String 'Get-KrbAccount.Start' -StringValues $Identity
        $krbtgt = Get-ADUser @parameter -ErrorAction Stop
        Write-PSFMessage -String 'Get-KrbAccount.UserFound' -StringValues $krbtgt.DistinguishedName -Level Debug
        
        $result = [PSCustomObject]@{
            PSTypeName               = 'Krbtgt.Account'
            EarliestResetTimestamp = $null
            Name                   = $krbtgt.Name
            SamAccountName           = $krbtgt.SamAccountName
            DistinguishedName       = $krbtgt.DistinguishedName
            PasswordLastSet           = $krbtgt.PasswordLastSet
            MaxTgtLifetimeHours    = 10
            MaxClockSkewMinutes    = 5
        }
        #endregion Get basic account properties
        
        #region Retrieve Kerberos Policies
        try
        {
            Write-PSFMessage -String 'Get-KrbAccount.ScanningKerberosPolicy' -StringValues $domainObject.DNSRoot
            if ($Credential)
            {
                [xml]$gpo = Invoke-PSFCommand -ComputerName $domainObject.PDCEmulator -Credential $Credential -ScriptBlock {
                    param ($DomainName)
                    Get-GPOReport -Guid '{31B2F340-016D-11D2-945F-00C04FB984F9}' -ReportType Xml -ErrorAction Stop -Domain $DomainName -Server localhost
                } -ErrorAction Stop -ArgumentList $domainObject.DNSRoot
            }
            [xml]$gpo = Get-GPOReport -Guid '{31B2F340-016D-11D2-945F-00C04FB984F9}' -ReportType Xml -ErrorAction Stop -Domain $domainObject.DNSRoot
            $result.MaxTgtLifetimeHours = (($gpo.gpo.Computer.ExtensionData | Where-Object { $_.name -eq 'Security' }).Extension.ChildNodes | Where-Object { $_.Name -eq 'MaxTicketAge' }).SettingNumber
            $result.MaxClockSkewMinutes = (($gpo.gpo.Computer.ExtensionData | Where-Object { $_.name -eq 'Security' }).Extension.ChildNodes | Where-Object { $_.Name -eq 'MaxClockSkew' }).SettingNumber
        }
        catch
        {
            Write-PSFMessage -Level Warning -String 'Get-KrbAccount.FailedKerberosPolicyLookup' -StringValues $domainObject.DNSRoot -ErrorRecord $_
        }
        #endregion Retrieve Kerberos Policies
        
        # This calculates the latest validity time of existing krbtgt tickets from before the last reset might have.
        # Resetting the krbtgt password again before this expiry time risks preventing DCs from synchronizing the password on the second reset!
        $result.EarliestResetTimestamp = (($Krbtgt.PasswordLastSet.AddHours($result.MaxTgtLifetimeHours)).AddMinutes($result.MaxClockSkewMinutes)).AddMinutes($result.MaxClockSkewMinutes)
        
        Write-PSFMessage -String 'Get-KrbAccount.Success' -StringValues $result.SamAccountName, $result.EarliestResetTimestamp
        $result
    }
}


function Reset-KrbPassword
{
<#
    .SYNOPSIS
        Resets the krbtgt account's password.
     
    .DESCRIPTION
        Resets the krbtgt account's password.
        Performs test runs to ensure functionality.
     
    .PARAMETER DomainController
        An explicit list of domain controllers to manually replicate.
        Optional, defaults to all domain controllers.
     
    .PARAMETER PDCEmulator
        The PDCEmulator to work against.
        Will default against the local domain's PDC Emulator.
        The actual password reset is executed against this computer, all manual replication commands will replicate with this.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER MaxDurationSeconds
        The maximum execution duration for the reset.
        Exceeding this duration will NOT interrupt the switch, but:
        - If exceeded during the test phase, the test will fail and the reset will be cancelled
        - If exceeded during execution, the overall result will be considered failed, even if technically a success.
     
    .PARAMETER DCSuccessPercent
        The percent of DCs that must successfully replicate the change in order to be considered a success.
        Defaults to 80% success rate.
        DC Replication commands are given by WinRM.
     
    .PARAMETER SkipTest
        Disables testing before execution.
     
    .PARAMETER Force
        By default, this command will refuse to reset the krbtgt account when there can still be a valid Kerberos ticket from before the last reset.
        Essentially, this means there is a cooldown after each krbtgt password reset.
        Using this parameter disables this barrier.
        DANGER: Using this parameter may lead to massive service interruption!!!
        Only use this in a case of utter desperation.
     
    .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:\> Reset-KrbPassword
     
        Resets the current domain's krbtgt account.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [PSFComputer[]]
        $DomainController,
        
        [PSFComputer]
        $PDCEmulator,
        
        [PSCredential]
        $Credential,
        
        [int]
        $MaxDurationSeconds = (Get-PSFConfigValue -FullName 'Krbtgt.Reset.MaxDurationSeconds' -Fallback 100),
        
        [int]
        $DCSuccessPercent = (Get-PSFConfigValue -FullName 'Krbtgt.Reset.DCSuccessPercent' -Fallback 100),
        
        [switch]
        $SkipTest,
        
        [switch]
        $Force,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Resolve names & DCs to process
        Write-PSFMessage -String 'Reset-KrbPassword.DomainResolve'
        $credParam = @{ }
        if ($Credential) { $credParam = @{ Credential = $Credential } }
        $parameters = $credParam.Clone()
        try
        {
            if ($PDCEmulator) { $pdcEmulatorInternal = $PDCEmulator }
            else { $pdcEmulatorInternal = (Get-ADDomain @credParam -ErrorAction Stop).PDCEmulator }
            $parameters.Server = $pdcEmulatorInternal
            
            $rwDomainControllers = Get-ADDomainController @parameters -Filter { IsReadOnly -eq $false } -ErrorAction Stop | Where-Object {
                ($_.Name -ne $pdcEmulatorInternal.ComputerName) -and ("$($_.Name).$($_.Forest)" -ne $pdcEmulatorInternal.ComputerName)
            }
            if ($DomainController)
            {
                $rwDomainControllers = $rwDomainControllers | Where-Object ComputerName -in $DomainController
            }
            Write-PSFMessage -String 'Reset-KrbPassword.DomainResolve.Success' -StringValues $pdcEmulatorInternal
        }
        catch
        {
            Stop-PSFFunction -String 'Reset-KrbPassword.DomainResolve.Failed' -ErrorRecord $_
            return
        }
        #endregion Resolve names & DCs to process
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        $report = [PSCustomObject]@{
            PSTypeName  = 'Krbtgt.ResetResult'
            PDCEmulator = $pdcEmulatorInternal
            Account        = $null
            Test        = $null
            Reset        = $null
            Sync        = $null
            Success        = $false
            Error        = @()
            Start        = $null
            End            = $null
            Duration    = $null
        }
        
        #region Access Information on the krbtgt account
        try
        {
            Write-PSFMessage -String 'Reset-KrbPassword.ReadKrbtgt'
            $report.Account = Get-KrbAccount @parameters -EnableException
            Write-PSFMessage -String 'Reset-KrbPassword.ReadKrbtgt.Success' -StringValues $report.Account
        }
        catch
        {
            $report.Error += $_
            Stop-PSFFunction -String 'Reset-KrbPassword.ReadKrbtgt.Failed' -ErrorRecord $_ -Cmdlet $PSCmdlet
            return $report
        }
        # Terminate if it is too soon to reset the password again
        if (-not $Force -and ($report.Account.EarliestResetTimestamp -gt (Get-Date)))
        {
            $report.Error += Write-Error "Cannot reset krbtgt password yet. Wait until $($report.Account.EarliestResetTimestamp) before trying again" -ErrorAction Continue 2>&1
            Stop-PSFFunction -String 'Reset-KrbPassword.ReadKrbtgt.TooSoon' -StringValues $report.Account.EarliestResetTimestamp -Cmdlet $PSCmdlet -ErrorRecord $report.Error -OverrideExceptionMessage
            return $report
        }
        #endregion Access Information on the krbtgt account
        
        #region Perform tests if not disabled
        if (-not $SkipTest)
        {
            Write-PSFMessage -String 'Reset-KrbPassword.TestReset'
            $report.Test = Test-KrbPasswordReset @credParam -MaxDurationSeconds $MaxDurationSeconds -PDCEmulator $pdcEmulatorInternal -DomainController $rwDomainControllers -DCSuccessPercent $DCSuccessPercent
            if ($report.Test.Errors)
            {
                Write-PSFMessage -Level Warning -String 'Reset-KrbPassword.TestReset.ErrorCount' -StringValues ($report.Test.Errors | Measure-Object).Count
                foreach ($errorItem in $report.Test.Errors)
                {
                    Write-PSFMessage -Level Warning -String 'Reset-KrbPassword.TestReset.ErrorItem' -StringValues $errorItem.Exception.Message
                    $report.Error += $errorItem
                }
            }
            if (-not $report.Test.Success)
            {
                $report.Test.Status = $report.Test.Status.Trim(", ")
                $report.Error = Write-Error "Test Reset Failed: $($report.Test.Status)" 2>&1
                Stop-PSFFunction -String 'Reset-KrbPassword.TestReset.Failed' -StringValues $report.Test.Status -ErrorRecord $report.Error -Cmdlet $PSCmdlet -OverrideExceptionMessage
                return $report
            }
        }
        #endregion Perform tests if not disabled
        
        $report.Start = Get-Date
        
        #region Reset Krbtgt Password on PDC
        try
        {
            Write-PSFMessage -String 'Reset-KrbPassword.ActualReset'
            Reset-UserPassword @parameters -Identity 'krbtgt' -EnableException
            Write-PSFMessage -String 'Reset-KrbPassword.ActualReset.Success'
            $report.Reset = $true
        }
        catch
        {
            $report.Reset = $false
            $report.Error += $_
            Stop-PSFFunction -String 'Reset-KrbPassword.ActualReset.Failed' -ErrorRecord $_ -Cmdlet $PSCmdlet
            return $report
        }
        #endregion Reset Krbtgt Password on PDC
        
        #region Resync Domain Controllers
        Write-PSFMessage -String 'Reset-KrbPassword.SyncAccount'
        $report.Sync = Sync-KrbAccount @credParam -SourceDC $rwDomainControllers -TargetDC $pdcEmulatorInternal
        $report.End = Get-Date
        $report.Duration = $report.End - $report.Start
        Write-PSFMessage -String 'Reset-KrbPassword.ResetDuration' -StringValues $report.Duration
        $countSuccess = ($report.Sync | Where-Object Success | Measure-Object).Count
        $successPercent = $countSuccess / ($report.Sync | Measure-Object).Count * 100
        if ($successPercent -lt $DCSuccessPercent)
        {
            Stop-PSFFunction -String 'Reset-KrbPassword.SyncAccount.FailedCount' -StringValues $successPercent, $DCSuccessPercent, (($report.Sync | Where-Object Success -eq $false).ComputerName -join ', ')
            return $report
        }
        if ($MaxDurationSeconds -lt $report.Duration.TotalSeconds)
        {
            Stop-PSFFunction -String 'Reset-KrbPassword.SyncAccount.FailedDuration' -StringValues $report.Duration, $MaxDurationSeconds -Cmdlet $PSCmdlet
            return $report
        }
        #endregion Resync Domain Controllers
        
        Write-PSFMessage -String 'Reset-KrbPassword.Success'
        $report.Success = $true
        return $report
    }
}


function Reset-KrbRODCPassword
{
<#
    .SYNOPSIS
        Reset the password on RODC krbtgt accounts.
     
    .DESCRIPTION
        Reset the password on RODC krbtgt accounts.
     
    .PARAMETER Name
        Name filter for what RODC to affect.
     
    .PARAMETER Server
        The directory server to initially work against.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Force
        By default, this command will refuse to reset the krbtgt account when there can still be a valid Kerberos ticket from before the last reset.
        Essentially, this means there is a cooldown after each krbtgt password reset.
        Using this parameter disables this barrier.
        DANGER: Using this parameter may lead to service interruption!
        Only use this in a case of utter desperation.
     
    .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:\> Reset-KrbRODCPassword
     
        Resets the password of all RODC krbtgt accounts.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [string]
        $Name = "*",
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $Force,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Resolve names & DCs to process
        $credParam = @{ }
        if ($Credential) { $credParam = @{ Credential = $Credential } }
        $parameters = $credParam.Clone()
        
        if ($Server) { $pdcEmulatorInternal = $Server }
        else
        {
            try
            {
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ResolvePDC'
                $pdcEmulatorInternal = (Get-ADDomain @credParam).PDCEmulator
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ResolvePDC.Success' -StringValues $pdcEmulatorInternal
            }
            catch
            {
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.ResolvePDC.Failed' -ErrorRecord $_ -Cmdlet $PSCmdlet
                return
            }
        }
        $parameters.Server = $pdcEmulatorInternal
        #endregion Resolve names & DCs to process
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        foreach ($rodc in (Get-RODomainController @parameters -Name $Name))
        {
            $report = [PSCustomObject]@{
                PSTypeName = 'Krbtgt.RODCResetResult'
                Server  = $rodc.DnsHostName
                Account = $null
                Reset   = $null
                Sync    = $null
                Success = $false
                Error   = @()
                Start   = $null
                End        = $null
                Duration = $null
            }
            
            #region Access Information on the krbtgt account
            try
            {
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ReadKrbtgt' -StringValues $rodc.DnsHostName
                $report.Account = Get-KrbAccount @parameters -Identity $rodc.KerberosAccount -EnableException
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ReadKrbtgt.Success' -StringValues $report.Account
            }
            catch
            {
                $report.Error += $_
                $report
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.ReadKrbtgt.Failed' -StringValues $rodc.DnsHostName -ErrorRecord $_ -Cmdlet $PSCmdlet -Continue
            }
            # Terminate if it is too soon to reset the password again
            if (-not $Force -and ($report.Account.EarliestResetTimestamp -gt (Get-Date)))
            {
                $report.Error += Write-Error "Cannot reset krbtgt password for $($rodc.DnsHostName) yet. Wait until $($report.Account.EarliestResetTimestamp) before trying again" -ErrorAction Continue 2>&1
                $report
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.ReadKrbtgt.TooSoon' -StringValues $rodc.DnsHostName, $report.Account.EarliestResetTimestamp -Cmdlet $PSCmdlet -ErrorRecord $report.Error -Continue -OverrideExceptionMessage
            }
            #endregion Access Information on the krbtgt account
            
            $report.Start = Get-Date
            
            #region Reset Krbtgt Password on PDC
            try
            {
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ActualReset' -StringValues $rodc.DnsHostName
                Reset-UserPassword @credParam -Server $rodc.ReplicationPartner[0] -Identity $rodc.KerberosAccount -EnableException
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ActualReset.Success' -StringValues $rodc.DnsHostName
                $report.Reset = $true
            }
            catch
            {
                $report.Reset = $false
                $report.Error += $_
                $report
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.ActualReset.Failed' -StringValues $rodc.DnsHostName -ErrorRecord $_ -Cmdlet $PSCmdlet -Continue
            }
            #endregion Reset Krbtgt Password on PDC
            
            #region Resync Domain Controllers
            Write-PSFMessage -String 'Reset-KrbRODCPassword.SyncAccount' -StringValues $rodc.DnsHostName, $rodc.ReplicationPartner[0]
            $report.Sync = Sync-KrbAccount @credParam -SourceDC $rodc.DnsHostName -TargetDC $rodc.ReplicationPartner[0]
            $report.End = Get-Date
            $report.Duration = $report.End - $report.Start
            Write-PSFMessage -String 'Reset-KrbRODCPassword.ResetDuration' -StringValues $rodc.DnsHostName, $report.Duration
            if ($report.Sync | Where-Object Success -EQ $false)
            {
                $report
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.SyncAccount.Failed' -StringValues $rodc.KerberosAccount, $rodc.DnsHostName, (($report.Sync | Where-Object Success -EQ $false).ComputerName -join ", ") -Cmdlet $PSCmdlet -Continue
            }
            #endregion Resync Domain Controllers
            
            Write-PSFMessage -String 'Reset-KrbRODCPassword.Success' -StringValues $rodc.DnsHostName
            $report.Success = $true
            $report
        }
    }
}

function Sync-KrbAccount
{
<#
    .SYNOPSIS
        Forces a single item AD Replication.
     
    .DESCRIPTION
        Will command the replication of an AD User object between two DCs.
        Uses PowerShell remoting against the source DC(s).
     
    .PARAMETER SourceDC
        The DC to start the synchronization command from.
     
    .PARAMETER TargetDC
        The DC to replicate with.
     
    .PARAMETER Credential
        Credentials to use for performing AD actions
     
    .PARAMETER Identity
        The user identity to replicate.
        Defaults to krbtgt.
     
    .PARAMETER ReplicationMode
        Whether to trigger replication through WinRM or LDAP.
        Defaults to LDAP
     
    .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:\> Sync-KrbAccount -SourceDC 'dc1' -TargetDC 'dc2'
         
        Replicates the krbtgt account between dc1 and dc2.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [PSFComputer[]]
        $SourceDC,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TargetDC,
        
        [System.Management.Automation.PSCredential]
        $Credential,
        
        [string]
        $Identity = 'krbtgt',
        
        [ValidateSet('LDAP', 'WinRM')]
        [string]
        $ReplicationMode = (Get-PSFConfigValue -FullName 'Krbtgt.Sync.Protocol' -Fallback 'LDAP'),
        
        [switch]
        $EnableException
    )
    
    begin
    {
        $credParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        try { $krbtgtDN = (Get-ADUser @credParam -Identity $Identity -Server $TargetDC -ErrorAction Stop).DistinguishedName }
        catch
        {
            Stop-PSFFunction -String 'Sync-KrbAccount.UserNotFound' -StringValues $Identity, $TargetDC -ErrorRecord $_
            return
        }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        $errorVar = @()
        $pwdLastSet = [System.DateTime]::FromFileTimeUtc((Get-ADObject @credParam -Identity $krbtgtDN -Server $TargetDC -Properties PwdLastSet).PwdLastSet)
        switch ($ReplicationMode)
        {
            #region LDAP Based
            'LDAP'
            {
                Sync-LdapObjectParallel @credParam -Object $krbtgtDN -Server $SourceDC -Target $TargetDC -Reverse
            }
            #endregion LDAP Based
            #region WinRM Based
            'WinRM'
            {
                Write-PSFMessage -String 'Sync-KrbAccount.Connecting' -StringValues ($SourceDC -join ', '), $krbtgtDN -Target $SourceDC
                Invoke-PSFCommand @credParam -ComputerName $SourceDC -ScriptBlock {
                    param (
                        $TargetDC,
                        
                        $KrbtgtDN,
                        
                        $PwdLastSet
                    )
                    
                    $message = repadmin.exe /replsingleobj $env:COMPUTERNAME $TargetDC $KrbtgtDN *>&1
                    $result = 0 -eq $LASTEXITCODE
                    
                    # Verify the password change was properly synced
                    $pwdLastSetLocal = [System.DateTime]::FromFileTimeUtc((Get-ADObject -Identity $KrbtgtDN -Server $env:COMPUTERNAME -Properties PwdLastSet).PwdLastSet)
                    if ($pwdLastSetLocal -ne $PwdLastSet) { $result = $false }
                    
                    [PSCustomObject]@{
                        ComputerName = $env:COMPUTERNAME
                        Success         = $result
                        Message         = ($message | Where-Object { $_ })
                        ExitCode     = $LASTEXITCODE
                        Error         = $null
                    }
                } -ArgumentList $TargetDC, $krbtgtDN, $pwdLastSet -ErrorVariable errorVar -ErrorAction SilentlyContinue | Select-PSFObject -KeepInputObject -TypeName 'Krbtgt.SyncResult'
                
                foreach ($errorObject in $errorVar)
                {
                    Write-PSFMessage -Level Warning -String 'Sync-KrbAccount.ConnectError' -StringValues $errorObject.TargetObject -ErrorRecord $errorObject
                    [PSCustomObject]@{
                        PSTypeName   = 'Krbtgt.SyncResult'
                        ComputerName = $errorObject.TargetObject
                        Success         = $false
                        Message         = $errorObject.Exception.Message
                        ExitCode     = 1
                        Error         = $errorObject
                    }
                }
            }
            #endregion WinRM Based
        }
    }
}

function Test-KrbPasswordReset
{
<#
    .SYNOPSIS
        Tests the account reset and synchronization functionality.
     
    .DESCRIPTION
        Tests the account reset and synchronization functionality.
        This is a dry run of what Reset-KrbPassword would do, executed using a temporary user account.
     
    .PARAMETER PDCEmulator
        The PDC Emulator to operate against.
     
    .PARAMETER DomainController
        The domain controller to synchronize with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER MaxDurationSeconds
        The maximum number of seconds a switch may take before being considered a failure.
        Defaults to 180 seconds
     
    .PARAMETER DCSuccessPercent
        The percent of DCs that need to successfully finish execution in order for this test to be considered a success.
        Defaults to 80 percent
     
    .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-KrbPasswordReset -PDCEmulator 'dc1.domain.com' -DomainController 'dc2', 'dc3'
     
        Tests the account password reset using a dummy account and returns, whether the execution would have been successful.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [string]
        $PDCEmulator = (Get-ADDomain).PDCEmulator,
        
        [PSFComputer[]]
        $DomainController,
        
        [PSCredential]
        $Credential,
        
        [int]
        $MaxDurationSeconds = (Get-PSFConfigValue -FullName 'Krbtgt.Reset.MaxDurationSeconds' -Fallback 100),
        
        [int]
        $DCSuccessPercent = (Get-PSFConfigValue -FullName 'Krbtgt.Reset.DCSuccessPercent' -Fallback 100),
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Ensure Domain Controller parameter is filled
        $parameters = @{ Server = $PDCEmulator }
        $credParam = @{ }
        if ($Credential)
        {
            $parameters['Credential'] = $Credential
            $credParam = @{ Credential = $Credential }
        }
        
        if (-not $DomainController)
        {
            try
            {
                $DomainController = (Get-ADDomainController @parameters -Filter * -ErrorAction Stop).HostName | Where-Object {
                    $_ -ne $PDCEmulator
                }
            }
            catch
            {
                Stop-PSFFunction -String 'Test-KrbPasswordReset.FailedDCResolution' -StringValues $PDCEmulator -ErrorRecord $_
                return
            }
        }
        #endregion Ensure Domain Controller parameter is filled
        
        #region Create a test account to test SO replication with
        try
        {
            $randomName = "krbtgt_test_$(Get-Random -Minimum 100 -Maximum 999)"
            Write-PSFMessage -String 'Test-KrbPasswordReset.CreatingCanary' -StringValues $randomName
            $canaryAccount = New-ADUser -Name $randomName -PassThru @parameters -ErrorAction Stop
        }
        catch
        {
            Stop-PSFFunction -String 'Test-KrbPasswordReset.FailedCanaryCreation' -StringValues $randomName -ErrorRecord $_
            return
        }
        try {
            $null = Sync-LdapObjectParallel @credParam -Object $canaryAccount -Server $DomainController -Target $PDCEmulator -Reverse
        }
        catch {
            # We don't care
        }
        #endregion Create a test account to test SO replication with
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        $result = [PSCustomObject]@{
            PSTypeName  = 'Krbtgt.TestResult'
            PDCEmulator = $PDCEmulator
            Start        = $null
            End            = $null
            Duration    = $null
            Reset        = $false
            Sync        = @()
            DCTotal        = ($DomainController | Measure-Object).Count
            DCSuccess   = 0
            DCSuccessPercent = 0
            DCFailed    = @()
            Errors        = @()
            Success        = $true
            Status        = $null
            RWDCs        = $DomainController
        }
        
        $result.Start = Get-Date
        
        #region Test 1: Password Reset
        Write-PSFMessage -String 'Test-KrbPasswordReset.ResettingPassword' -StringValues $canaryAccount.DistinguishedName -Target $canaryAccount.DistinguishedName
        try
        {
            Reset-UserPassword @parameters -Identity $canaryAccount.DistinguishedName -EnableException
            $result.Reset = $true
        }
        catch
        {
            Write-PSFMessage -Level Warning -String 'Test-KrbPasswordReset.ResettingPasswordFailed' -StringValues $canaryAccount.DistinguishedName -Target $canaryAccount.DistinguishedName -ErrorRecord $_
            $result.Reset = $false
            $result.Errors += $_
            $result.Success = $false
            $result.Status = $result.Status, 'ResetError' -join ", "
        }
        #endregion Test 1: Password Reset
        
        #region Test 2: Resync Domain Controllers
        Write-PSFMessage -String 'Test-KrbPasswordReset.SynchronizingCanary' -StringValues $canaryAccount.DistinguishedName -Target $canaryAccount.DistinguishedName
        $result.Sync = Sync-KrbAccount @credParam -SourceDC $DomainController -TargetDC $PDCEmulator -Identity $canaryAccount.DistinguishedName -EnableException:$false
        $result.End = Get-Date
        $result.Duration = $result.End - $result.Start
        $result.DCSuccess = $result.Sync | Where-Object Success
        $result.DCFailed =  $result.Sync | Where-Object Success -EQ $false
        $result.DCSuccessPercent = ($result.DCSuccess | Measure-Object).Count / $result.DCTotal * 100
        $result.Sync.Error | ForEach-Object {
            if ($_) { $result.Errors += $_ }
        }
        if ($result.Duration.TotalSeconds -gt $MaxDurationSeconds)
        {
            $result.Success = $false
            $result.Status = $result.Status, 'TooSlowError' -join ", "
        }
        if ($result.DCSuccessPercent -lt $DCSuccessPercent)
        {
            $result.Success = $false
            $result.Status = $result.Status, 'SyncErrorRateError' -join ", "
        }
        Write-PSFMessage -String 'Test-KrbPasswordReset.Concluded' -StringValues $result.Success, $result.Status, $canaryAccount.DistinguishedName -Target $canaryAccount.DistinguishedName
        #endregion Test 2: Resync Domain Controllers
        
        $result
    }
    end
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        # Remove the test account after finishing its work
        try { $canaryAccount | Remove-ADUser @parameters -Confirm:$false -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -String 'Test-KrbPasswordReset.FailedCanaryCleanup' -StringValues $canaryAccount.DistinguishedName
        }
    }
}


Register-PSFConfigValidation -Name "Krbtgt.SyncProtocol" -ScriptBlock {
    param (
        $Value
    )
    
    $Result = New-Object PSObject -Property @{
        Success = $True
        Value   = $null
        Message = ""
    }
    if ($Value -notin 'LDAP', 'WinRM')
    {
        $Result.Message = "Not a supported protocol: $Value. Pick either LDAP or WinRM!"
        $Result.Success = $False
        return $Result
    }
    
    $Result.Value = $Value -as [string]
    
    return $Result
}

<#
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 'Krbtgt' -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 'Krbtgt' -Name 'Import.DoDotSource' -Value $true -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 'Krbtgt' -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."

Set-PSFConfig -Module 'Krbtgt' -Name 'Reset.DCSuccessPercent' -Value 100 -Initialize -Validation 'integer' -Description 'The default minimum percentage of DCs that must successfully replicate a password reset request in order for the reset to be considered successful.'
Set-PSFConfig -Module 'Krbtgt' -Name 'Reset.MaxDurationSeconds' -Value 180 -Initialize -Validation 'integer' -Description 'The default maximum replication duration (in seconds) in order for the reset to be considered successful.'
Set-PSFConfig -Module 'Krbtgt' -Name 'Sync.Protocol' -Value 'LDAP' -Initialize -Validation 'Krbtgt.SyncProtocol' -Description 'The protocol to use when performing single-item replication, syncing the password.'

<#
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 'Krbtgt.ScriptBlockName' -Scriptblock {
     
}
#>


Register-PSFTeppScriptblock -Name "Krbtgt.PDC" -ScriptBlock {
    Get-PSFTaskEngineCache -Module krbtgt -Name PDCs
}

Register-PSFTeppScriptblock -Name "Krbtgt.DC" -ScriptBlock {
    $dcs = Get-PSFTaskEngineCache -Module krbtgt -Name DCs
    if ($fakeBoundParameters.PDCEmulator)
    {
        $dcs[(Get-ADDomain -Server $fakeBoundParameters.PDCEmulator).DNSRoot]
    }
    elseif ($fakeBoundParameters.Server)
    {
        $dcs[(Get-ADDomain -Server $fakeBoundParameters.Server).DNSRoot]
    }
    else
    {
        $dcs[(Get-ADDomain).DNSRoot]
    }
}

Register-PSFTeppScriptblock -Name "Krbtgt.RODC" -ScriptBlock {
    $rodcs = Get-PSFTaskEngineCache -Module krbtgt -Name RODCs
    if ($fakeBoundParameters.PDCEmulator)
    {
        $rodcs[(Get-ADDomain -Server $fakeBoundParameters.PDCEmulator).DNSRoot]
    }
    elseif ($fakeBoundParameters.Server)
    {
        $rodcs[(Get-ADDomain -Server $fakeBoundParameters.Server).DNSRoot]
    }
    else
    {
        $rodcs[(Get-ADDomain).DNSRoot]
    }
}

Register-PSFTeppArgumentCompleter -Command Get-KrbAccount -Parameter Server -Name Krbtgt.PDC

Register-PSFTeppArgumentCompleter -Command Reset-KrbPassword -Parameter PDCEmulator -Name Krbtgt.PDC
Register-PSFTeppArgumentCompleter -Command Reset-KrbPassword -Parameter DomainController -Name Krbtgt.DC

Register-PSFTeppArgumentCompleter -Command Reset-KrbRODCPassword -Parameter Server -Name Krbtgt.PDC
Register-PSFTeppArgumentCompleter -Command Reset-KrbRODCPassword -Parameter Name -Name Krbtgt.RODC

Register-PSFTeppArgumentCompleter -Command Sync-KrbAccount -Parameter SourceDC, TargetDC -Name Krbtgt.DC

Register-PSFTeppArgumentCompleter -Command Test-KrbPasswordReset -Parameter PDCEmulator -Name Krbtgt.PDC
Register-PSFTeppArgumentCompleter -Command Test-KrbPasswordReset -Parameter DomainController -Name Krbtgt.DC

New-PSFLicense -Product 'Krbtgt' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-04-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.
"@

#endregion Load compiled code