provisioning.ps1

#Requires -Version 5.0

# Native offline domain join binding - compiled once, reused by
# New-OfflineDomainJoinBlob. P/Invoke port of the well-established technique
# from lazywinadmin/PowerShell (AD-COMPUTER-New-ADDomainJoin), which attributes
# its own source (WinPENanoDomainJoin). The 2012+ API path
# (NetCreateProvisioningPackage) is used exclusively - this module targets
# Windows 10/11/Server 2016 and newer.
if ($null -eq ('PSFOfflineJoin.Native' -as [type])) {
  Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
 
namespace PSFOfflineJoin
{
    public class Native
    {
        [StructLayout(LayoutKind.Sequential)]
        public class NetSetupProvisioningParams
        {
            public uint dwVersion;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpDomain;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpHostName;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpMachineAccountOU;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpDcName;
            public uint dwProvisionOptions;
            public IntPtr aCertTemplateNames;
            public uint cCertTemplateNames;
            public IntPtr aMachinePolicyNames;
            public uint cMachinePolicyNames;
            public IntPtr aMachinePolicyPaths;
            public uint cMachinePolicyPaths;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpNetbiosName;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpSiteName;
            [MarshalAs(UnmanagedType.LPWStr)] public string lpPrimaryDNSDomain;
        }
 
        [DllImport("netapi32.dll", EntryPoint = "NetCreateProvisioningPackage", SetLastError = true, ExactSpelling = true, CharSet = CharSet.Unicode)]
        public static extern int NetCreateProvisioningPackage(
            NetSetupProvisioningParams pProvisioningParams,
            IntPtr ppPackageBinData,
            IntPtr pdwPackageBinDataSize,
            [MarshalAs(UnmanagedType.LPWStr)] out string ppPackageText);
 
        [DllImport("advapi32.dll", SetLastError = true)]
        public static extern bool LogonUser(string username, string domain, string password, int logonType, int logonProvider, out IntPtr token);
 
        [DllImport("advapi32.dll", SetLastError = true)]
        public static extern bool DuplicateToken(IntPtr existingToken, int impersonationLevel, out IntPtr duplicateToken);
 
        [DllImport("advapi32.dll")]
        public static extern bool ImpersonateLoggedOnUser(IntPtr token);
 
        [DllImport("advapi32.dll")]
        public static extern bool RevertToSelf();
 
        [DllImport("kernel32.dll")]
        public static extern bool CloseHandle(IntPtr handle);
 
        public const int LOGON32_LOGON_NEW_CREDENTIALS = 9;
        public const int LOGON32_PROVIDER_WINNT50 = 3;
        public const int SECURITY_IMPERSONATION = 2;
 
        public static string CreateProvisioningPackage(string domain, string machine, string ou, string dc, uint options, string username, string password)
        {
            if (!string.IsNullOrEmpty(username))
            {
                string userDomain = username;
                string user = username;
                int idx = username.IndexOf('\\');
                if (idx >= 0)
                {
                    userDomain = username.Substring(0, idx);
                    user = username.Substring(idx + 1);
                }
 
                IntPtr token = IntPtr.Zero;
                IntPtr dupToken = IntPtr.Zero;
                try
                {
                    if (!LogonUser(user, userDomain, password, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_WINNT50, out token))
                        throw new Exception("LogonUser failed with Win32 error " + Marshal.GetLastWin32Error());
                    if (!DuplicateToken(token, SECURITY_IMPERSONATION, out dupToken))
                        throw new Exception("DuplicateToken failed with Win32 error " + Marshal.GetLastWin32Error());
                    if (!ImpersonateLoggedOnUser(dupToken))
                        throw new Exception("ImpersonateLoggedOnUser failed with Win32 error " + Marshal.GetLastWin32Error());
                    return CallNetCreateProvisioningPackage(domain, machine, ou, dc, options);
                }
                finally
                {
                    RevertToSelf();
                    if (dupToken != IntPtr.Zero) CloseHandle(dupToken);
                    if (token != IntPtr.Zero) CloseHandle(token);
                }
            }
            return CallNetCreateProvisioningPackage(domain, machine, ou, dc, options);
        }
 
        private static string CallNetCreateProvisioningPackage(string domain, string machine, string ou, string dc, uint options)
        {
            NetSetupProvisioningParams p = new NetSetupProvisioningParams();
            p.dwVersion = 1;
            p.lpDomain = domain;
            p.lpHostName = machine;
            p.lpMachineAccountOU = ou;
            p.lpDcName = dc;
            p.dwProvisionOptions = options;
            string blob = string.Empty;
            int res = NetCreateProvisioningPackage(p, IntPtr.Zero, IntPtr.Zero, out blob);
            if (res != 0) throw new Exception("NetCreateProvisioningPackage failed with error " + res);
            return blob;
        }
    }
}
'@
 -ErrorAction Stop
}

function New-OfflineDomainJoinBlob {
  <#
    .SYNOPSIS
      New-OfflineDomainJoinBlob - Provisions an AD computer account and returns the offline-join blob.
    .DESCRIPTION
      Fully native offline domain join: provisions the computer account in
      Active Directory and generates the join blob via the
      NetCreateProvisioningPackage Win32 API (P/Invoke) - no djoin.exe
      dependency. When -Credential is supplied, the API runs under that
      account's impersonated context; otherwise the current context is used.
 
      The returned blob is consumed by New-DjoinFile (or djoin.exe /loadfile)
      on the target machine - join it during unattended setup or first boot,
      before it has live DC connectivity.
    .PARAMETER ComputerName
      Computer account name to provision.
    .PARAMETER Domain
      Domain (DNS or NetBIOS) to join.
    .PARAMETER Credential
      Optional credential with rights to create computer accounts. When
      omitted, the current context is used.
    .PARAMETER MachineAccountOU
      Optional distinguished name of the OU for the machine account, e.g.
      'OU=Computers,DC=contoso,DC=com'.
    .PARAMETER DCName
      Optional target domain controller.
    .PARAMETER ProvisionOptions
      NetCreateProvisioningPackage provisioning options. Defaults to 2
      (NETSETUP_PROVISION_REUSE_ACCOUNT, matching the reference behaviour).
    .OUTPUTS
      PSCustomObject - New-OperationResult-shaped result with a Blob property.
    .EXAMPLE
      # AD-side: provision the computer account and generate the join blob.
      PS> $result = New-OfflineDomainJoinBlob -ComputerName 'SRV001' -Domain 'contoso.com' -Credential (Get-Credential) -MachineAccountOU 'OU=Servers,DC=contoso,DC=com'
 
      # Target-side: write the djoin-format file, then copy it to the new machine.
      PS> New-DjoinFile -Blob $result.Blob -DestinationFile 'C:\join\SRV001.djoin'
 
      # On the target machine, join offline during unattended setup / first boot:
      PS> djoin.exe /loadfile SRV001.djoin /reuse
    .LINK
      https://github.com/adnoctem/winkit/lib/provisioning.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  param (
    [Parameter(Mandatory = $true)]
    [Alias('MachineName')]
    [string]
    $ComputerName,

    [Parameter(Mandatory = $true)]
    [string]
    $Domain,

    [Parameter(Mandatory = $false)]
    [System.Management.Automation.PSCredential]
    $Credential,

    [Parameter(Mandatory = $false)]
    [string]
    $MachineAccountOU,

    [Parameter(Mandatory = $false)]
    [string]
    $DCName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 4096)]
    [uint32]
    $ProvisionOptions = 2
  )

  if (-not $PSCmdlet.ShouldProcess("$ComputerName in $Domain", 'Provision computer account and generate offline-join blob')) {
    if ($WhatIfPreference) {
      New-OperationResult -Target $ComputerName -Source 'NetAPI32' -Action 'ProvisionAccount' -Status 'DryRun' -Detail "Would provision '$ComputerName' in '$Domain'."
    }
    return
  }

  try {
    $blob = [PSFOfflineJoin.Native]::CreateProvisioningPackage(
      $Domain,
      $ComputerName,
      $MachineAccountOU,
      $DCName,
      $ProvisionOptions,
      $(if ($Credential) { $Credential.UserName } else { $null }),
      $(if ($Credential) { $Credential.GetNetworkCredential().Password } else { $null }))

    if ([string]::IsNullOrWhiteSpace($blob)) {
      New-OperationResult -Target $ComputerName -Source 'NetAPI32' -Action 'ProvisionAccount' -Status 'Failed' -ErrorMessage 'The API returned an empty blob.'
    }
    else {
      New-OperationResult -Target $ComputerName -Source 'NetAPI32' -Action 'ProvisionAccount' -Status 'Completed' -Detail "Provisioned '$ComputerName' in '$Domain'." -Property @{ Blob = $blob }
    }
  }
  catch {
    New-OperationResult -Target $ComputerName -Source 'NetAPI32' -Action 'ProvisionAccount' -Status 'Failed' -ErrorMessage $_.Exception.Message
  }
}

function New-DjoinFile {
  <#
    .SYNOPSIS
      New-DjoinFile - Writes an offline-join blob in the format djoin.exe consumes.
    .DESCRIPTION
      Writes the blob produced by New-OfflineDomainJoinBlob (or djoin.exe
      /requestjoindomain /saveold) to a file in the format Windows Setup and
      djoin.exe /loadfile expect: UTF-16LE with a BOM, blob data, and two
      terminating null bytes.
    .PARAMETER Blob
      The offline-join blob string.
    .PARAMETER DestinationFile
      Full path of the file to write. Defaults to C:\temp\djoin.tmp.
    .OUTPUTS
      PSCustomObject - New-OperationResult-shaped result.
    .EXAMPLE
      # AD-side: provision the computer account and generate the join blob.
      PS> $result = New-OfflineDomainJoinBlob -ComputerName 'SRV001' -Domain 'contoso.com' -Credential (Get-Credential) -MachineAccountOU 'OU=Servers,DC=contoso,DC=com'
 
      # Target-side: write the djoin-format file, then copy it to the new machine.
      PS> New-DjoinFile -Blob $result.Blob -DestinationFile 'C:\join\SRV001.djoin'
 
      # On the target machine, join offline during unattended setup / first boot:
      PS> djoin.exe /loadfile SRV001.djoin /reuse
 
      # New-DjoinFile also accepts blobs obtained elsewhere, e.g. from a
      # reference machine: djoin.exe /requestjoindomain /saveold C:\join\REF.djoin
    .LINK
      https://github.com/adnoctem/winkit/lib/provisioning.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $Blob,

    [Parameter(Mandatory = $false)]
    [System.IO.FileInfo]
    $DestinationFile = 'C:\temp\djoin.tmp'
  )

  if (-not $PSCmdlet.ShouldProcess($DestinationFile.FullName, 'Write offline-join blob file')) {
    if ($WhatIfPreference) {
      New-OperationResult -Target $DestinationFile.FullName -Source 'DJoin' -Action 'WriteBlob' -Status 'DryRun' -Detail 'No file written.'
    }
    return
  }

  try {
    $byteChain = [byte[]]::new(2)
    $byteChain[0] = 255
    $byteChain[1] = 254

    $fileStream = $DestinationFile.OpenWrite()
    try {
      $byteChain += [System.Text.Encoding]::Unicode.GetBytes($Blob)
      $byteChain += 0
      $byteChain += 0
      $fileStream.Write($byteChain, 0, $byteChain.Length)
    }
    finally {
      $fileStream.Close()
    }

    New-OperationResult -Target $DestinationFile.FullName -Source 'DJoin' -Action 'WriteBlob' -Status 'Completed' -Detail "Wrote $($byteChain.Length) bytes."
  }
  catch {
    New-OperationResult -Target $DestinationFile.FullName -Source 'DJoin' -Action 'WriteBlob' -Status 'Failed' -ErrorMessage $_.Exception.Message
  }
}