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 $true
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
    )
    
    if ($doDotSource) { . (Resolve-Path $Path) }
    else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText((Resolve-Path $Path)))), $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 '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-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.
     
    .EXAMPLE
        PS C:\> Get-RODomainController
     
        Returns information on all RODCs in the current domain.
#>

    [CmdletBinding()]
    param (
        [string]
        $Name = "*",
        
        [string]
        $Server
    )
    
    begin
    {
        $parameter = @{
            LdapFilter = "(&(primaryGroupID=521)(name=$Name))"
            Properties = 'msDS-KrbTgtLink'
        }
        if ($Server) { $parameter["Server"] = $Server }
    }
    process
    {
        $rodcs = Get-ADComputer @parameter
        foreach ($rodc in $rodcs)
        {
            $domainDN = ($rodc.DistinguishedName -split "," | Where-Object { $_ -like "DC=*" }) -join ','
            $siteServerObjects = Get-ADObject -LDAPFilter "(&(objectClass=server)(dnsHostName=$($rodc.DNSHostName)))" -SearchBase "CN=Sites,CN=Configuration,$($domainDN)"
            $replicationPartner = @()
            foreach ($siteServerObject in $siteServerObjects)
            {
                $fromServer = (Get-ADObject -SearchBase $siteServerObject.DistinguishedName -LDAPFilter '(objectClass=nTDSConnection)' -Properties FromServer).FromServer
                $replicationPartner += (Get-ADObject $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-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 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", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [string]
        $Server,

        [SecureString]
        $Password = (New-Password -AsSecureString),
        
        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = @{
            Identity = $Identity
            NewPassword = $Password
            ErrorAction = 'Stop'
        }
        if ($Server) { $parameters["Server"] = $Server }
    }
    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 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 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.
#>

    [CmdletBinding()]
    param (
        [string]
        $Server,
        
        [string]
        $Identity = 'krbtgt',
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Prepare Preliminaries
        $parameter = @{
            Identity   = $Identity
            Properties = 'PasswordLastSet'
        }
        if ($Server) { $parameter['Server'] = $Server }
        
        try
        {
            if ($Server) { $domainName = (Get-ADDomain -Server $Server -ErrorAction Stop).DNSRoot }
            else { $domainName = (Get-ADDomain -ErrorAction Stop).DNSRoot }
        }
        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 $domainName
            [xml]$gpo = Get-GPOReport -Guid '{31B2F340-016D-11D2-945F-00C04FB984F9}' -ReportType Xml -ErrorAction Stop -Domain $domainName
            $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 $domainName -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 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", "")]
    [CmdletBinding()]
    param (
        [PSFComputer[]]
        $DomainController,
        
        [PSFComputer]
        $PDCEmulator,
        
        [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'
        try
        {
            if ($PDCEmulator) { $pdcEmulatorInternal = $PDCEmulator }
            else { $pdcEmulatorInternal = (Get-ADDomain -ErrorAction Stop).PDCEmulator }
            $rwDomainControllers = Get-ADDomainController -Filter { IsReadOnly -eq $false } -Server $pdcEmulatorInternal -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 -Server $pdcEmulatorInternal -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 -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.Error = Write-Error "Test Reset Failed: $($report.Test.StatusCode)" 2>&1
                Stop-PSFFunction -String 'Reset-KrbPassword.TestReset.Failed' -StringValues $report.Test.StatusCode -ErrorRecord $report.Error -Cmdlet $PSCmdlet
                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 -Server $pdcEmulatorInternal -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 -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 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", "")]
    [CmdletBinding()]
    param (
        [string]
        $Name = "*",
        
        [PSFComputer]
        $Server,
        
        [switch]
        $Force,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Resolve names & DCs to process
        if ($Server) { $pdcEmulatorInternal = $Server }
        else
        {
            try
            {
                Write-PSFMessage -String 'Reset-KrbRODCPassword.ResolvePDC'
                $pdcEmulatorInternal = (Get-ADDomain).PDCEmulator
            }
            catch
            {
                Stop-PSFFunction -String 'Reset-KrbRODCPassword.ResolvePDC.Failed' -ErrorRecord $_ -Cmdlet $PSCmdlet
                return
            }
        }
        Write-PSFMessage -String 'Reset-KrbRODCPassword.ResolvePDC.Success' -StringValues $pdcEmulatorInternal
        #endregion Resolve names & DCs to process
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        foreach ($rodc in (Get-RODomainController -Name $Name -Server $Server))
        {
            $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 -Server $pdcEmulatorInternal -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 -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 -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 Identity
        The user identity to replicate.
        Defaults to krbtgt.
     
    .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,
        
        [string]
        $Identity = 'krbtgt',
        
        [switch]
        $EnableException
    )
    
    begin
    {
        try { $krbtgtDN = (Get-ADUser -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 -Identity $krbtgtDN -Server $TargetDC -Properties PwdLastSet).PwdLastSet)
        Write-PSFMessage -String 'Sync-KrbAccount.Connecting' -StringValues ($SourceDC -join ', '), $krbtgtDN -Target $SourceDC
        Invoke-PSFCommand -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 -Message '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
            }
        }
    }
}

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 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.
#>

    [CmdletBinding()]
    param (
        [string]
        $PDCEmulator = (Get-ADDomain).PDCEmulator,
        
        [PSFComputer[]]
        $DomainController,
        
        [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
        if (-not $DomainController)
        {
            try
            {
                $DomainController = (Get-ADDomainController -Server $PDCEmulator -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 -Server $PDCEmulator -ErrorAction Stop
        }
        catch
        {
            Stop-PSFFunction -String 'Test-KrbPasswordReset.FailedCanaryCreation' -StringValues $randomName -ErrorRecord $_
            return
        }
        #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        = ''
            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 -Server $PDCEmulator -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 -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.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 -Server $PDCEmulator -Confirm:$false -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -String 'Test-KrbPasswordReset.FailedCanaryCleanup' -StringValues $canaryAccount.DistinguishedName
        }
    }
}


<#
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.'

<#
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