From 48512fa9d15680ab6d303327832e64332154dfde Mon Sep 17 00:00:00 2001 From: Gary Sharp Date: Wed, 25 Feb 2026 14:34:34 +1100 Subject: [PATCH] qol: offline domain join to reuse AD computer accounts Replaces old behaviour of deleting and creating new accounts. Now when a device has a new name, its existing account is renamed and reused. --- Disco.Client/Extensions/EnrolExtensions.cs | 66 ++++++++---- .../Enrolment/WindowsDeviceEnrolment.cs | 15 ++- Disco.Services/Disco.Services.csproj | 1 + .../ADDeviceOfflineDomainJoining.cs | 100 ++++++++++++++++++ .../ActiveDirectory/ADDomainController.cs | 89 ---------------- .../ActiveDirectory/ADMachineAccount.cs | 23 ++++ .../ActiveDirectory/ActiveDirectory.cs | 12 --- 7 files changed, 174 insertions(+), 132 deletions(-) create mode 100644 Disco.Services/Interop/ActiveDirectory/ADDeviceOfflineDomainJoining.cs diff --git a/Disco.Client/Extensions/EnrolExtensions.cs b/Disco.Client/Extensions/EnrolExtensions.cs index 64348bb3..129a5086 100644 --- a/Disco.Client/Extensions/EnrolExtensions.cs +++ b/Disco.Client/Extensions/EnrolExtensions.cs @@ -2,8 +2,7 @@ using Disco.Models.ClientServices; using Microsoft.Win32; using System; -using System.Diagnostics; -using System.IO; +using System.Runtime.InteropServices; namespace Disco.Client.Extensions { @@ -61,6 +60,28 @@ namespace Disco.Client.Extensions Program.AllowUninstall = enrolResponse.AllowBootstrapperUninstall; } + [Flags] + private enum NETSETUP_PROVISION_FLAGS : int + { + NETSETUP_PROVISION_DOWNLEVEL_PRIV_SUPPORT = 0x00000001, + NETSETUP_PROVISION_REUSE_ACCOUNT = 0x00000002, + NETSETUP_PROVISION_USE_DEFAULT_PASSWORD = 0x00000004, + NETSETUP_PROVISION_SKIP_ACCOUNT_SEARCH = 0x00000008, + NETSETUP_PROVISION_ROOT_CA_CERTS = 0x00000010, + NETSETUP_PROVISION_PERSISTENTSITE = 0x00000020, + NETSETUP_PROVISION_ONLINE_CALLER = 0x40000000, + NETSETUP_PROVISION_CHECK_PWD_ONLY = unchecked((int)0x80000000), + } + + [DllImport("Netapi32.dll", CallingConvention = CallingConvention.Winapi)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int NetRequestOfflineDomainJoin( + [In] IntPtr pProvisionBinData, + [In, MarshalAs(UnmanagedType.I4)] int cbProvisionBinDataSize, + [In, MarshalAs(UnmanagedType.I4)] NETSETUP_PROVISION_FLAGS dwOptions, + [In, MarshalAs(UnmanagedType.LPWStr)] string lpWindowsPath + ); + /// /// Processes a Client Service Enrol Response for Offline Domain Join Actions /// @@ -72,30 +93,31 @@ namespace Disco.Client.Extensions { Presentation.UpdateStatus("Enrolling Device", $"Performing Offline Domain Join:\r\nRenaming Computer: {Environment.MachineName} -> {enrolResponse.ComputerName}", true, -1, 1500); - string odjFile = Path.GetTempFileName(); - File.WriteAllBytes(odjFile, Convert.FromBase64String(enrolResponse.OfflineDomainJoinManifest)); + var provisionData = Convert.FromBase64String(enrolResponse.OfflineDomainJoinManifest); + string systemRoot = Environment.GetEnvironmentVariable("SystemRoot"); - string odjWindowsPath = Environment.GetEnvironmentVariable("SystemRoot"); - string odjProcessArguments = $"/REQUESTODJ /LOADFILE \"{odjFile}\" /WINDOWSPATH \"{odjWindowsPath}\" /LOCALOS"; - - ProcessStartInfo odjProcessStartInfo = new ProcessStartInfo("DJOIN.EXE", odjProcessArguments) + var provisionDataPointer = Marshal.AllocCoTaskMem(provisionData.Length); + Marshal.Copy(provisionData, 0, provisionDataPointer, provisionData.Length); + var joinResult = default(int); + try { - CreateNoWindow = true, - ErrorDialog = false, - LoadUserProfile = true, - RedirectStandardOutput = true, - UseShellExecute = false - }; - string odjResult; - using (Process odjProcess = System.Diagnostics.Process.Start(odjProcessStartInfo)) - { - odjResult = odjProcess.StandardOutput.ReadToEnd(); - odjProcess.WaitForExit(20000); // 20 Seconds + joinResult = NetRequestOfflineDomainJoin(provisionDataPointer, provisionData.Length, NETSETUP_PROVISION_FLAGS.NETSETUP_PROVISION_ONLINE_CALLER, systemRoot); + } + finally + { + Marshal.FreeCoTaskMem(provisionDataPointer); } - Presentation.UpdateStatus("Enrolling Device", $"Offline Domain Join Result:\r\n{odjResult}", true, -1, 3000); - if (File.Exists(odjFile)) - File.Delete(odjFile); + if (joinResult != 0) + { + var win32Exception = new System.ComponentModel.Win32Exception(joinResult); + Presentation.UpdateStatus("Enrolling Device", $"Offline Domain Join Failed:\r\n{win32Exception.Message} [{joinResult}]", true, -1, 3000); + throw new InvalidOperationException($"Offline Domain Join Failed:\r\n{win32Exception.Message} [{joinResult}]"); + } + else + { + Presentation.UpdateStatus("Enrolling Device", $"Offline Domain Join Succeeded", true, -1, 2000); + } // Flush Logged-On History if (enrolResponse.SetAssignedUserForLogon && !string.IsNullOrEmpty(enrolResponse.DomainName)) diff --git a/Disco.Services/Devices/Enrolment/WindowsDeviceEnrolment.cs b/Disco.Services/Devices/Enrolment/WindowsDeviceEnrolment.cs index ecf5f2bd..7cc95e9b 100644 --- a/Disco.Services/Devices/Enrolment/WindowsDeviceEnrolment.cs +++ b/Disco.Services/Devices/Enrolment/WindowsDeviceEnrolment.cs @@ -188,9 +188,11 @@ namespace Disco.Services.Devices.Enrolment { if (!authenticatedToken.Has(Claims.ComputerAccount)) throw new EnrolmentSafeException($"Connection not correctly authenticated (SN: {Request.SerialNumber}; Auth User: {authenticatedToken.User.UserId})"); + else if (!string.Equals($"{Request.ComputerName}$", authenticatedToken.User.UserId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Connection not correctly authenticated (SN: {Request.SerialNumber}; Computer Name: {Request.ComputerName}; Auth User: {authenticatedToken.User.UserId})"); - if (domain == null) - domain = ActiveDirectory.Context.GetDomainByName(Request.DNSDomainName); + if (domain == null && !ActiveDirectory.Context.TryGetDomainByName(Request.DNSDomainName, out domain)) + throw new EnrolmentSafeException($"The specified domain name '{Request.DNSDomainName}' is not recognized or reachable."); if (!authenticatedToken.User.UserId.Equals($@"{domain.NetBiosName}\{Request.ComputerName}$", StringComparison.OrdinalIgnoreCase)) throw new EnrolmentSafeException($"Connection not correctly authenticated (SN: {Request.SerialNumber}; Auth User: {authenticatedToken.User.UserId})"); @@ -392,9 +394,7 @@ namespace Disco.Services.Devices.Enrolment EnrolmentLog.LogSessionTaskProvisioningADAccount(sessionId, device.SerialNumber, device.DeviceDomainId); adMachineAccount = domainController.Value.RetrieveADMachineAccount(device.DeviceDomainId); - response.OfflineDomainJoinManifest = domainController.Value.OfflineDomainJoinProvision(device.DeviceDomainId, device.DeviceProfile.OrganisationalUnit, ref adMachineAccount, out var offlineProvisionDiagnosicInfo); - - EnrolmentLog.LogSessionDiagnosticInformation(sessionId, offlineProvisionDiagnosicInfo); + response.OfflineDomainJoinManifest = domainController.Value.OfflineDomainJoinProvision(device.DeviceDomainId, device.DeviceProfile.OrganisationalUnit, ref adMachineAccount); response.RequireReboot = true; } @@ -441,10 +441,7 @@ namespace Disco.Services.Devices.Enrolment response.ComputerName = calculatedAccountUsername; // Create New Account - - response.OfflineDomainJoinManifest = domainController.Value.OfflineDomainJoinProvision(device.DeviceDomainId, device.DeviceProfile.OrganisationalUnit, ref adMachineAccount, out var offlineProvisionDiagnosicInfo); - - EnrolmentLog.LogSessionDiagnosticInformation(sessionId, offlineProvisionDiagnosicInfo); + response.OfflineDomainJoinManifest = domainController.Value.OfflineDomainJoinProvision(device.DeviceDomainId, device.DeviceProfile.OrganisationalUnit, ref adMachineAccount); response.RequireReboot = true; } diff --git a/Disco.Services/Disco.Services.csproj b/Disco.Services/Disco.Services.csproj index fca0d1c5..8e5ce064 100644 --- a/Disco.Services/Disco.Services.csproj +++ b/Disco.Services/Disco.Services.csproj @@ -464,6 +464,7 @@ + diff --git a/Disco.Services/Interop/ActiveDirectory/ADDeviceOfflineDomainJoining.cs b/Disco.Services/Interop/ActiveDirectory/ADDeviceOfflineDomainJoining.cs new file mode 100644 index 00000000..b304a922 --- /dev/null +++ b/Disco.Services/Interop/ActiveDirectory/ADDeviceOfflineDomainJoining.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Disco.Services.Interop.ActiveDirectory +{ + public static class ADDeviceOfflineDomainJoining + { + [Flags] + private enum NETSETUP_PROVISION_FLAGS : int + { + NETSETUP_PROVISION_DOWNLEVEL_PRIV_SUPPORT = 0x00000001, + NETSETUP_PROVISION_REUSE_ACCOUNT = 0x00000002, + NETSETUP_PROVISION_USE_DEFAULT_PASSWORD = 0x00000004, + NETSETUP_PROVISION_SKIP_ACCOUNT_SEARCH = 0x00000008, + NETSETUP_PROVISION_ROOT_CA_CERTS = 0x00000010, + NETSETUP_PROVISION_PERSISTENTSITE = 0x00000020, + NETSETUP_PROVISION_ONLINE_CALLER = 0x40000000, + NETSETUP_PROVISION_CHECK_PWD_ONLY = unchecked((int)0x80000000), + } + + [DllImport("Netapi32.dll", CallingConvention = CallingConvention.Winapi)] + [return: MarshalAs(UnmanagedType.I4)] + private static extern int NetProvisionComputerAccount( + [In, MarshalAs(UnmanagedType.LPWStr)] string lpDomain, + [In, MarshalAs(UnmanagedType.LPWStr)] string lpMachineName, + [In, Optional, MarshalAs(UnmanagedType.LPWStr)] string lpMachineAccountOU, + [In, Optional, MarshalAs(UnmanagedType.LPWStr)] string lpDcName, + [In, MarshalAs(UnmanagedType.I4)] NETSETUP_PROVISION_FLAGS dwOptions, + out IntPtr pProvisionBinData, + out int pdwProvisionBinDataSize, + [Optional] IntPtr pProvisionTextData + ); + + [DllImport("Netapi32.dll", SetLastError = true)] + private static extern int NetApiBufferFree(IntPtr Buffer); + + public static string OfflineDomainJoinProvision(this ADDomainController dc, string computerSamAccountName, string organisationalUnit, ref ADMachineAccount machineAccount) + { + if (machineAccount != null && machineAccount.IsCriticalSystemObject) + throw new InvalidOperationException($"This account {machineAccount.DistinguishedName} is a Critical System Active Directory Object and Disco ICT refuses to modify it"); + + if (!dc.IsWritable) + throw new InvalidOperationException($"The domain controller [{dc.Name}] is not writable. This action (Offline Domain Join Provision) requires a writable domain controller."); + + if (!string.IsNullOrWhiteSpace(computerSamAccountName)) + computerSamAccountName = computerSamAccountName.TrimEnd('$'); + if (!string.IsNullOrWhiteSpace(computerSamAccountName) && computerSamAccountName.Contains('\\')) + computerSamAccountName = computerSamAccountName.Substring(computerSamAccountName.IndexOf('\\') + 1); + + // NetBIOS Limit (16 characters; "{ComputerName}$"; 15 characters allowed) + if (string.IsNullOrWhiteSpace(computerSamAccountName) || computerSamAccountName.Length > 15) + throw new ArgumentException("Invalid Computer Name; > 0 and <= 15", "ComputerName"); + + // Ensure Specified OU Exists + if (!string.IsNullOrEmpty(organisationalUnit)) + { + try + { + using (var deOU = dc.RetrieveDirectoryEntry(organisationalUnit, new string[] { "distinguishedName" })) + { + if (deOU == null) + throw new Exception($"OU's Directory Entry couldn't be found at [{organisationalUnit}]"); + } + } + catch (Exception ex) + { + throw new ArgumentException($"An error occurred while trying to locate the specified OU: {organisationalUnit}", "OrganisationalUnit", ex); + } + } + + if (machineAccount != null && !string.Equals(machineAccount.Name, computerSamAccountName, StringComparison.Ordinal)) + { + // rename the account + machineAccount.RenameAccount(dc, computerSamAccountName); + } + + var result = NetProvisionComputerAccount(dc.Domain.Name, computerSamAccountName, string.IsNullOrWhiteSpace(organisationalUnit) ? null : organisationalUnit, dc.Name, NETSETUP_PROVISION_FLAGS.NETSETUP_PROVISION_REUSE_ACCOUNT, out var provisionDataPointer, out var provisionDataLength); + + if (result != 0) + { + var win32Exception = new System.ComponentModel.Win32Exception(result); + throw new InvalidOperationException($"NetProvisionComputerAccount failed with error code {result}: {win32Exception.Message}"); + } + + if (provisionDataPointer == IntPtr.Zero || provisionDataLength == 0) + throw new InvalidOperationException("NetProvisionComputerAccount did not return valid provisioning data."); + + var buffer = new byte[provisionDataLength]; + Marshal.Copy(provisionDataPointer, buffer, 0, provisionDataLength); + var freeResult = NetApiBufferFree(provisionDataPointer); + var encodedResult = Convert.ToBase64String(buffer); + + // Reload Machine Account + machineAccount = dc.RetrieveADMachineAccount($@"{dc.Domain.NetBiosName}\{computerSamAccountName}", (machineAccount == null ? null : machineAccount.LoadedProperties.Keys.ToArray())); + + return encodedResult; + } + } +} diff --git a/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs b/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs index bd0a2662..15182304 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs @@ -1,12 +1,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.DirectoryServices; using System.DirectoryServices.ActiveDirectory; using System.Linq; using System.Net.NetworkInformation; using System.Security.Principal; -using System.Text; namespace Disco.Services.Interop.ActiveDirectory { @@ -353,93 +351,6 @@ namespace Disco.Services.Interop.ActiveDirectory return (pr.Status == IPStatus.Success); } } - - public string OfflineDomainJoinProvision(string ComputerSamAccountName, string OrganisationalUnit, ref ADMachineAccount MachineAccount, out string DiagnosticInformation) - { - if (MachineAccount != null && MachineAccount.IsCriticalSystemObject) - throw new InvalidOperationException($"This account {MachineAccount.DistinguishedName} is a Critical System Active Directory Object and Disco ICT refuses to modify it"); - - if (!IsWritable) - throw new InvalidOperationException($"The domain controller [{Name}] is not writable. This action (Offline Domain Join Provision) requires a writable domain controller."); - - StringBuilder diagnosticInfo = new StringBuilder(); - string DJoinResult = null; - - if (!string.IsNullOrWhiteSpace(ComputerSamAccountName)) - ComputerSamAccountName = ComputerSamAccountName.TrimEnd('$'); - if (!string.IsNullOrWhiteSpace(ComputerSamAccountName) && ComputerSamAccountName.Contains('\\')) - ComputerSamAccountName = ComputerSamAccountName.Substring(ComputerSamAccountName.IndexOf('\\') + 1); - - // NetBIOS Limit (16 characters; "{ComputerName}$"; 15 characters allowed) - if (string.IsNullOrWhiteSpace(ComputerSamAccountName) || ComputerSamAccountName.Length > 15) - throw new ArgumentException("Invalid Computer Name; > 0 and <= 15", "ComputerName"); - - // Ensure Specified OU Exists - if (!string.IsNullOrEmpty(OrganisationalUnit)) - { - try - { - using (var deOU = RetrieveDirectoryEntry(OrganisationalUnit, new string[] { "distinguishedName" })) - { - if (deOU == null) - throw new Exception($"OU's Directory Entry couldn't be found at [{OrganisationalUnit}]"); - } - } - catch (Exception ex) - { - throw new ArgumentException($"An error occurred while trying to locate the specified OU: {OrganisationalUnit}", "OrganisationalUnit", ex); - } - } - - if (MachineAccount != null) - MachineAccount.DeleteAccount(this); - - string tempFileName = System.IO.Path.GetTempFileName(); - string argumentOU = (!string.IsNullOrWhiteSpace(OrganisationalUnit)) ? $" /MACHINEOU \"{OrganisationalUnit}\"" : string.Empty; - string arguments = $"/PROVISION /DOMAIN \"{Domain.Name}\" /DCNAME \"{Name}\" /MACHINE \"{ComputerSamAccountName}\"{argumentOU} /REUSE /SAVEFILE \"{tempFileName}\""; - ProcessStartInfo commandStarter = new ProcessStartInfo("DJOIN.EXE", arguments) - { - CreateNoWindow = true, - ErrorDialog = false, - LoadUserProfile = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - diagnosticInfo.AppendFormat($"DJOIN.EXE {arguments}"); - diagnosticInfo.AppendLine(); - - string stdOutput; - string stdError; - using (Process commandProc = Process.Start(commandStarter)) - { - commandProc.WaitForExit(20000); - stdOutput = commandProc.StandardOutput.ReadToEnd(); - stdError = commandProc.StandardError.ReadToEnd(); - } - if (!string.IsNullOrWhiteSpace(stdOutput)) - diagnosticInfo.AppendLine(stdOutput); - if (!string.IsNullOrWhiteSpace(stdError)) - diagnosticInfo.AppendLine(stdError); - - if (System.IO.File.Exists(tempFileName)) - { - DJoinResult = Convert.ToBase64String(System.IO.File.ReadAllBytes(tempFileName)); - System.IO.File.Delete(tempFileName); - } - if (string.IsNullOrWhiteSpace(DJoinResult)) - throw new InvalidOperationException( -$@"Domain Join Unsuccessful -Error: {stdError} -Output: {stdOutput}"); - - DiagnosticInformation = diagnosticInfo.ToString(); - - // Reload Machine Account - MachineAccount = RetrieveADMachineAccount($@"{Domain.NetBiosName}\{ComputerSamAccountName}", (MachineAccount == null ? null : MachineAccount.LoadedProperties.Keys.ToArray())); - - return DJoinResult; - } #endregion public override string ToString() diff --git a/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs b/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs index 225f87f6..576a8b5b 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs @@ -204,6 +204,29 @@ namespace Disco.Services.Interop.ActiveDirectory #region Actions + public void RenameAccount(ADDomainController writableDomainController, string newName) + { + if (IsCriticalSystemObject) + throw new InvalidOperationException($"This account [{DistinguishedName}] is a Critical System Active Directory Object and Disco ICT refuses to modify it"); + + if (!writableDomainController.IsWritable) + throw new InvalidOperationException($"The domain controller [{Name}] is not writable. This action (Delete Account) requires a writable domain controller."); + + using (ADDirectoryEntry adEntry = writableDomainController.RetrieveDirectoryEntry(DistinguishedName)) + { + var entry = adEntry.Entry; + entry.Properties["dNSHostName"][0] = $"{newName}.{Domain.Name}"; + entry.Properties["sAMAccountName"][0] = $"{newName}$"; + entry.CommitChanges(); + entry.Rename($"CN={newName}"); + entry.CommitChanges(); + + // Update Distinguished Name + Name = newName; + DistinguishedName = entry.Properties["distinguishedName"][0].ToString(); + } + } + public void DeleteAccount(ADDomainController WritableDomainController) { if (IsCriticalSystemObject) diff --git a/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs b/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs index 4d525399..8f96102a 100644 --- a/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs +++ b/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs @@ -179,18 +179,6 @@ namespace Disco.Services.Interop.ActiveDirectory } #endregion - #region Actions - - public static string OfflineDomainJoinProvision(string ComputerSamAccountName, string OrganisationalUnit, ref ADMachineAccount MachineAccount, out string DiagnosticInformation) - { - var domain = Context.GetDomainFromDistinguishedName(OrganisationalUnit); - var writableDomainController = domain.GetAvailableDomainController(RequireWritable: true); - - return writableDomainController.OfflineDomainJoinProvision(ComputerSamAccountName, OrganisationalUnit, ref MachineAccount, out DiagnosticInformation); - } - - #endregion - #region Helpers public static string ParseDomainAccountId(string AccountId)