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:
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 $truedsacls "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"
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.
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 -ForceAfter having made the changes, run the script on the machine you want to have a proxy endpoint on.
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\JoinUserCall 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 $credNote: The service account the internal endpoint is working with must have permissions on the given OU to create computer objects.
Appendix
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-Hostif ($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 @paramif ($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
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 @paramif ($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
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 Unicodecmd /c DJOIN /REQUESTODJ /LOADFILE $tempFile /WINDOWSPATH %windir% /LOCALOS
Remove-Item -Path $tempFile
if (-not $DoNotRestart)
{
Restart-Computer -Force
}