Automated Offline Domain Join using PowerShell and JEA

Abstract

An offline domain join requires network access to a writable Domain Controller, which might not be desired in a DMZ scenario. Also, permissions to create computer accounts are required and permissions to enable password replication to the Read-Only Domain Controller.
This document describes the process of delegating and fully automating this process by means of PowerShell Restricted Endpoints. The setup of the endpoints is also part of this document.

Expected Result

After completing the steps outlined in the document, dedicated user accounts with no administrative permission in Active Directory can do an offline domain join without the need to contact a writable Domain Controller. The offline domain join can be done completely from the machine to join and no further steps are required on the Read-Only nor a writable Domain Controller. This process offers a very high level of comfort while giving the least privileges required to the users responsible for joining machines into the Active Directory.

Audience

Technical personnel responsible to managing Active Directory and security officers.

Requirements

An Active Directory running at least Windows Server 2008 R2. Clients and servers in the DMZ can only communicate to Read-Only Domain Controllers. Writable Domain Controllers cannot be reached from the DMZ.

Windows Management Framework 5.1 must be installed on the servers that will host the PowerShell Restricted Endpoints.

A user account with no additional privileges but having the right to create computer accounts and add someone to the group "Allowed RODC Password Replication Group".

Technical documentation

PowerShell Restricted Endpoints - background

A custom PowerShell Restricted Endpoints works like the default endpoints that already exist on every computer with at least WMF2. When creating new endpoints, you can assign permissions so that only selected users can access this new endpoint. Furthermore, these endpoints can be configured to run with predefined credentials. This can be either domain credentials or a local virtual account. In this scenario, both types are used.

To control what the users working with a custom endpoint are allowed to do, PowerShell works in a white-list mode. Only commands and functions that are explicitly allowed can be used within the endpoint. In this scenario only a single function is allowed.

Further documentation can be found here:

Documentation how to further extend this concept with JEA:

PowerShell Restricted Endpoints in this specific scenario

The process in this scenario requires two endpoints. One proxy endpoint in the DMZ that the clients and servers to join can reach. The other endpoint is in the internal network and can only be reached from the proxy endpoint in the DMZ. The proxy endpoint in the DMZ just forwards the request to the one in the internal network.

The workflow is:

  • The client or server that should join the domain connects to the proxy endpoint in the DMZ.
  • The proxy endpoint just forwards the join request to the endpoint in the internal network.
  • The endpoint in the internal network does all the work to get the machine joined to the domain.
    • It checks if the site name is correct and the OU exists.
    • It creates the computer account using DJOIN.exe in the given OU. DJOIN.EXE creates a blob that must be stored on the machine to join. This blog is returned via the proxy endpoint to the machine requesting the domain join.
    • The machine is added to the group "Allowed RODC Password Replication Group"?.
    • If requested (PrepopulatePassword), the password can be pre-populated. This requires some special rights that are not part of this paper.

Security Aspects

None of the accounts used has administrative access to the Active Directory. The only access delegated to the user account running the internal endpoint is:

  • Creating computer accounts in the dedicated OUs
  • Adding a computer or user to the group "Allowed RODC Password Replication Group".

Even if the machines hosting the endpoints are compromised, no critical credentials are effected.

The Deployment

Note: All the scripts referenced are in the appendix at the bottom.

Two machines are required to deploy the solution, one in the DMZ and one in the internal network. The endpoints should not be deployed to domain controllers for security reasons.

A service account is required for the endpoint running in the internal network.

The required steps to deploy the solution are:

  1. Service Account

    The endpoint in the internal network runs with domain user credentials. The service user must have the right to create computer accounts in the particular OUs and to alter the member of the group "Allowed RODC Password Replication Group".

    The following commands create the user account and add the required permissions to the computers container and a test OU. It also grants the service account permissions to change the membership of the "Allowed RODC Password Replication" group.

    [code lang="PowerShell" gutter="false"]
    New-ADUser -Name JoinUser -AccountPassword ('Password1' | ConvertTo-SecureString -AsPlainText -Force) -Enabled $true

    dsacls "CN=Allowed RODC Password Replication Group,CN=Users,DC=contoso,DC=com" /G "contoso\OfflineDomainJoin:WP;member;"

    dsacls "OU=Test,DC=contoso,DC=com" /G "contoso\OfflineDomainJoin:GRGE;computer"

    dsacls "CN=Computers,DC=contoso,DC=com" /G "contoso\OfflineDomainJoin:GRGE;computer"

  2. Create the endpoint in the internal network

    Run the script in section RestrictedEndpoint LAN.ps1 to deploy the endpoint. Please change the highlighted section according to your environment.

    [code lang="PowerShell" gutter="false"]

    Register-SupportPSSessionConfiguration -RunAsUser contoso\OfflineDomainJoin -RunAsUserPassword Password1 -AllowedPrincipals contoso\zMgmtRodcs$ -Force

    Note: contoso\zMgmtRodcs$ must be replaced with the computer account of the machine in the DMZ where the proxy endpoint is running on. Please make sure that there is a $ at the end of the argument.

  3. Create the proxy endpoint in the internal network

    The script in the section RestrictedEndpoint DMZ.ps1 creates the proxy endpoint in the DMZ. This endpoint needs to know where the internal endpoint can be found. Change this line in the parameter block accordingly.

    [code lang="PowerShell" gutter="false"]
    [string]$Server = 'zMgmtLan.contoso.com'

    ,The other line that must be changes it the function call at the very bottom. Please replace the highlighted part with the users or groups that should be able to join computers to the domain. If you have more than one principal, provide a comma-separated list.

    [code lang="PowerShell" gutter="false"]
    Register-SupportPSSessionConfiguration -UseVirtualAccount -AllowedPrincipals contoso\JoinUser -Force

    After having made the changes, run the script on the machine you want to have a proxy endpoint on.

  4. Join a Machine Using the Automated Offline Domain Join

    On the machine you want to offline-join to the domain, get the credentials for a user that is allowed to connect to the proxy endpoint.

    [code lang="PowerShell" gutter="false"]
    $cred = Get-Credential -Credential contoso\JoinUser

    Call the script in section OfflineDomainJoinRequest.ps1. The highlighted parts should be changed according to your environment. The parameter “Server” takes the name of the machine hosting the proxy endpoint. The given site name should match the Active Directory site the machine is part of.

    [code lang="PowerShell" gutter="false"]
    C:\OfflineDomainJoinRequest.ps1 -Server zMgmtRodcs.contoso.com -SiteName DMZ -OrganizationalUnit 'OU=Test,DC=contoso,DC=com' -DoNotRestart -Credential $cred

    Note: The service account the internal endpoint is working with must have permissions on the given OU to create computer objects.

Appendix

  1. RestrictedEndpoint LAN.ps1

    [code lang="PowerShell"]
    function New-ADOfflineDomainJoin
    {
    param(
    [Parameter(Mandatory)]
    [string]$ComputerName,

    [string]$SiteName,

    [string]$OrganizationalUnit,

    [switch]$PrepopulatePassword
    )

    $domain = Get-ADDomain -Current LocalComputer
    Write-Host "Current domain is '$($domain.DNSRoot)'"
    $rodcs = $domain.ReadOnlyReplicaDirectoryServers
    Write-Host "$($rodcs.Count) Read-Only Domain Controllers found: $($rodcs -join ', ')"
    $writableDC = (Get-ADDomainController -Writable -Discover).HostName[0]
    Write-Host "Writable Domain Controller is '$writableDC'"

    if ($SiteName)
    {
    try
    {
    Get-ADReplicationSite -Identity $SiteName | Out-Null
    }
    catch
    {
    Write-Error "The Active Directory site '$SiteName' could not be found"
    return
    }
    }

    if ($OrganizationalUnit)
    {
    try
    {
    Get-ADOrganizationalUnit -Identity $OrganizationalUnit | Out-Null
    }
    catch
    {
    Write-Error "The Active Directory OU '$OrganizationalUnit' could not be found"
    return
    }
    }

    Write-Host
    $tempFile = [System.IO.Path]::GetTempFileName()
    Remove-Item -Path $tempFile
    Write-Host "Calling DJOIN.EXE..." -NoNewline

    $cmd = 'djoin.exe /provision /domain "{0}" /MACHINE {1} /SAVEFILE {2} /DCName {3}' -f $domain.DNSRoot, $ComputerName, $tempFile, $writableDC
    if ($OrganizationalUnit)
    {
    $cmd += " /MACHINEOU $OrganizationalUnit"
    }
    if ($SiteName)
    {
    $cmd += " /PSITE $SiteName"
    }

    Write-Host 'Running the following djoin.exe command:'
    Write-Host $cmd

    $djoinResult = &([scriptblock]::Create($cmd))

    if ($djoinResult -like '*Computer provisioning completed successfully*')
    {
    Write-Host 'successfull'
    }
    else
    {
    Write-Host "there was an error: $($djoinResult[-2])"
    return
    }
    Write-Host

    $computer = Get-ADComputer -Identity $ComputerName -Server $writableDC
    Write-Host "Adding computer account '$ComputerName' to group 'Allowed RODC Password Replication Group'"
    Add-ADGroupMember -Members $computer -Identity 'Allowed RODC Password Replication Group' -Server $writableDC
    Write-Host

    if ($PrepopulatePassword)
    {
    foreach ($rodc in $rodcs)
    {
    Write-Host "Prepopulating password for account '$($($computer.DistinguishedName))' to RODC '$rodc' from writable DC '$writableDC'..." -NoNewline
    $repadminResult = repadmin.exe /rodcpwdrepl $rodc $writableDC ""$($computer.DistinguishedName)""

    if ($repadminResult -like '*Successfully replicated secrets*')
    {
    Write-Host 'successfull'
    }
    else
    {
    Write-Host 'error'

    Write-Error ($repadminResult -join '. ')
    return
    }
    }
    Write-Host
    }

    Get-Content -Path $tempFile
    Remove-Item -Path $tempFile
    }

    function Register-SupportPSSessionConfiguration
    {
    param(
    [Parameter(Mandatory, ParameterSetName = 'UserAccount')]
    [string]$RunAsUser,

    [Parameter(Mandatory, ParameterSetName = 'UserAccount')]
    [string]$RunAsUserPassword,

    [Parameter(Mandatory, ParameterSetName = 'VirtualAccount')]
    [switch]$UseVirtualAccount,

    [string[]]$AllowedPrincipals,

    [switch]$Force
    )

    $modulesToImport = 'ActiveDirectory'

    $path = [System.IO.Path]::GetTempFileName()
    Remove-Item -Path $path
    $path = [System.IO.Path]::ChangeExtension($path, '.pssc')

    $endpointName = 'OfflineDomainJoin'

    if ($Force -and (Get-PSSessionConfiguration -Name $endpointName -ErrorAction SilentlyContinue))
    {
    Get-PSSessionConfiguration -Name $endpointName | Unregister-PSSessionConfiguration
    }

    $param = @{}
    $param.Add('Path', $path)
    $param.Add('ModulesToImport', $modulesToImport)
    $param.Add('SessionType', 'Default')
    $param.Add('LanguageMode', 'FullLanguage')
    $param.Add('VisibleProviders', 'FileSystem')
    $param.Add('ExecutionPolicy', 'Unrestricted')
    $param.Add('Full', $true)

    if ($UseVirtualAccount) { $param.Add('RunAsVirtualAccount', $true) }
    $param.Add('FunctionDefinitions', @{
    Name = 'New-ADOfflineDomainJoin'
    ScriptBlock = (Get-Command -Name New-ADOfflineDomainJoin).ScriptBlock
    }
    )
    New-PSSessionConfigurationFile @param

    if ($RunAsUser)
    {
    $cred = New-Object pscredential($RunAsUser, ($RunAsUserPassword | ConvertTo-SecureString -AsPlainText -Force))
    }

    $param = @{
    Name = $endpointName
    Path = $path
    Force = $Force
    }
    if ($RunAsUser) { $param.Add('RunAsCredential', $cred) }
    try
    {
    Register-PSSessionConfiguration @param -ErrorAction Stop
    }
    catch
    {
    Write-Error -Exception $_.Exception
    return
    }
    finally
    {
    Remove-Item -Path $path
    }

    $pssc = Get-PSSessionConfiguration -Name $endpointName
    $psscSd = New-Object System.Security.AccessControl.CommonSecurityDescriptor($false, $false, $pssc.SecurityDescriptorSddl)

    foreach ($allowedPrincipal in $AllowedPrincipals)
    {
    $account = New-Object System.Security.Principal.NTAccount($allowedPrincipal)
    $accessType = "Allow"
    $accessMask = 268435456
    $inheritanceFlags = "None"
    $propagationFlags = "None"
    $psscSd.DiscretionaryAcl.AddAccess($accessType,$account.Translate([System.Security.Principal.SecurityIdentifier]),$accessMask,$inheritanceFlags,$propagationFlags)
    }

    Set-PSSessionConfiguration -Name $endpointName -SecurityDescriptorSddl $psscSd.GetSddlForm("All") -Force
    }

    Register-SupportPSSessionConfiguration -RunAsUser contoso\OfflineDomainJoin -RunAsUserPassword Password1 -AllowedPrincipals contoso\zMgmtRodcs$ -Force

  2. RestrictedEndpoint DMZ.ps1

    [code lang="PowerShell"]
    function Request-ADOfflineDomainJoin
    {
    param(
    [Parameter(Mandatory)]
    [string]$ComputerName,

    [string]$SiteName,

    [string]$OrganizationalUnit,

    [string]$Server = 'zMgmtLan.contoso.com',

    [switch]$PrepopulatePassword
    )

    $s = New-PSSession -ComputerName $Server -ConfigurationName OfflineDomainJoin
    $blob = Invoke-Command -Session $s -ScriptBlock {

    $param = @{
    ComputerName = $using:ComputerName
    }
    if ($using:SiteName) { $param.Add('SiteName', $using:SiteName) }
    if ($using:OrganizationalUnit) { $param.Add('OrganizationalUnit', $using:OrganizationalUnit) }
    if ($using:PrepopulatePassword) { $param.Add('PrepopulatePassword', $true) }

    New-ADOfflineDomainJoin @param
    }

    $blob
    }

    function Register-SupportPSSessionConfiguration
    {
    param(
    [Parameter(Mandatory, ParameterSetName = 'UserAccount')]
    [string]$RunAsUser,

    [Parameter(Mandatory, ParameterSetName = 'UserAccount')]
    [string]$RunAsUserPassword,

    [Parameter(Mandatory, ParameterSetName = 'VirtualAccount')]
    [switch]$UseVirtualAccount,

    [string[]]$AllowedPrincipals,

    [switch]$Force
    )

    $modulesToImport = 'ActiveDirectory'

    $path = [System.IO.Path]::GetTempFileName()
    Remove-Item -Path $path
    $path = [System.IO.Path]::ChangeExtension($path, '.pssc')

    $endpointName = 'OfflineDomainJoinProxy'

    if ($Force -and (Get-PSSessionConfiguration -Name $endpointName -ErrorAction SilentlyContinue))
    {
    Get-PSSessionConfiguration -Name $endpointName | Unregister-PSSessionConfiguration
    }

    $param = @{}
    $param.Add('Path', $path)
    $param.Add('ModulesToImport', $modulesToImport)
    $param.Add('SessionType', 'Default')
    $param.Add('LanguageMode', 'FullLanguage')
    $param.Add('VisibleProviders', 'FileSystem')
    $param.Add('ExecutionPolicy', 'Unrestricted')
    $param.Add('Full', $true)

    if ($UseVirtualAccount) { $param.Add('RunAsVirtualAccount', $true) }
    $param.Add('FunctionDefinitions', @{
    Name = 'Request-ADOfflineDomainJoin'
    ScriptBlock = (Get-Command -Name Request-ADOfflineDomainJoin).ScriptBlock
    }
    )
    New-PSSessionConfigurationFile @param

    if ($RunAsUser)
    {
    $cred = New-Object pscredential($RunAsUser, ($RunAsUserPassword | ConvertTo-SecureString -AsPlainText -Force))
    }

    $param = @{
    Name = $endpointName
    Path = $path
    Force = $Force
    }
    if ($RunAsUser) { $param.Add('RunAsCredential', $cred) }
    try
    {
    Register-PSSessionConfiguration @param -ErrorAction Stop
    }
    catch
    {
    Write-Error -Exception $_.Exception
    return
    }
    finally
    {
    Remove-Item -Path $path
    }

    $pssc = Get-PSSessionConfiguration -Name $endpointName
    $psscSd = New-Object System.Security.AccessControl.CommonSecurityDescriptor($false, $false, $pssc.SecurityDescriptorSddl)

    foreach ($allowedPrincipal in $AllowedPrincipals)
    {
    $account = New-Object System.Security.Principal.NTAccount($allowedPrincipal)
    $accessType = "Allow"
    $accessMask = 268435456
    $inheritanceFlags = "None"
    $propagationFlags = "None"
    $psscSd.DiscretionaryAcl.AddAccess($accessType,$account.Translate([System.Security.Principal.SecurityIdentifier]),$accessMask,$inheritanceFlags,$propagationFlags)
    }

    Set-PSSessionConfiguration -Name $endpointName -SecurityDescriptorSddl $psscSd.GetSddlForm("All") -Force
    }

    Register-SupportPSSessionConfiguration -UseVirtualAccount -AllowedPrincipals contoso\JoinUser -Force

  3. OfflineDomainJoinRequest.ps1

    [code lang="PowerShell"]
    param(
    [Parameter(Mandatory)]
    [string]$Server,

    [Parameter(Mandatory)]
    [pscredential]$Credential,

    [string]$SiteName,

    [string]$OrganizationalUnit,

    [switch]$PrepopulatePassword,

    [switch]$DoNotRestart
    )

    Set-Item -Path WSMan:\localhost\Client\TrustedHosts -Value $Server -Force

    $computerName = $env:COMPUTERNAME
    $s = New-PSSession -ComputerName $Server -ConfigurationName OfflineDomainJoinProxy -Credential $Credential -ErrorAction Stop
    $blob = Invoke-Command -Session $s -ScriptBlock {
    $param = @{
    ComputerName = $using:ComputerName
    }

    if ($using:SiteName) { $param.Add('SiteName', $using:SiteName) }
    if ($using:OrganizationalUnit) { $param.Add('OrganizationalUnit', $using:OrganizationalUnit) }
    if ($using:PrepopulatePassword) { $param.Add('PrepopulatePassword', $true) }

    Request-ADOfflineDomainJoin @param
    }

    if (-not $blob)
    {
    Write-Error 'Failed to retreive blob for offline domain join'
    return
    }

    $tempFile = [System.IO.Path]::GetTempFileName()
    $blob | Set-Content -Path $tempFile -Encoding Unicode

    cmd /c DJOIN /REQUESTODJ /LOADFILE $tempFile /WINDOWSPATH %windir% /LOCALOS

    Remove-Item -Path $tempFile

    if (-not $DoNotRestart)
    {
    Restart-Computer -Force
    }