Private/Start-UserManipulation.ps1
|
function Start-UserManipulation { ################################################################################ ###### ##### ###### Bulk-manipulate AD users in parallel: ##### ###### 1. Reset password ##### ###### 2. Disable account ##### ###### 3. Write tick value to adminDescription ##### ###### ##### ################################################################################ param( [Parameter(Mandatory)] [string]$SearchBase, [Parameter(Mandatory)] [string]$Server, [string]$NewPassword, # auto-generated if omitted [string]$Action, [string]$TickValue = (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), [ValidateRange(1, 100)] [int]$ThrottleLimit = 5 ) $CurrentFunction = Get-FunctionName Write-Log -Message "### Start Function $CurrentFunction ###" $StartRunTime = (Get-Date).ToString($Script:DateFormatLog) #################### main code | out- host ##################### # Auto-generate password if none given if ([string]::IsNullOrWhiteSpace($NewPassword)) { $NewPassword = Get-RandomPassword } if ($Action -match "^Disable$") { $users = Get-ADUser ` -Filter "Enabled -eq 'True'" ` -SearchBase $SearchBase ` -Server $Server ` -Properties SamAccountName, SID $total = $users.Count if ($total -eq 0) { Invoke-Output -Type Warning -Message "No enabled users found under $SearchBase" return } } else { $users = Get-ADUser ` -Filter "*" ` -SearchBase $SearchBase ` -Server $Server ` -Properties SamAccountName, SID $total = $users.Count } $cname = Convert-FromDNToCN -DistinguishedName $SearchBase Invoke-Output -Type Quit -Message "Press 'Q' at any time to abort User Manipulation`n for '$total' users under '$cname'!`n Password: '$NewPassword' | Tick: '$TickValue'" $startTime = Get-Date # ---- PARALLEL WORKER ---- # Uses DirectoryServices.DirectoryEntry (raw LDAP on port 389) instead of the AD module. # This completely avoids ADWS and its connection pool limit — the root cause of # the "transient condition" error under parallel load. $manipulateWorker = { $dn = $_.DistinguishedName $sid = $_.SID.Value $sam = $_.SamAccountName $srv = $using:Server $pw = $using:NewPassword $tick = $using:TickValue $Manipulation = $using:Action $errors = [System.Collections.Generic.List[string]]::new() try { $entry = New-Object System.DirectoryServices.DirectoryEntry "LDAP://$srv/$dn" # 1. Reset password via LDAP SetPassword If ($Manipulation -match "^PwReset$|^Both$") { try { $entry.Invoke("SetPassword", $pw) $entry.Properties["adminDescription"].Value = $tick $entry.CommitChanges() } catch { $errors.Add("PwReset: $($_.Exception.InnerException.Message ?? $_.Exception.Message)") } } # 2+3. Disable account + write tick in a single LDAP roundtrip If ($Manipulation -match "^Disable$|^Both$") { try { $uac = [int]$entry.Properties["userAccountControl"].Value $entry.Properties["userAccountControl"].Value = $uac -bor 2 $entry.Properties["adminDescription"].Value = $tick $entry.CommitChanges() } catch { $errors.Add("Disable+AdminDesc: $($_.Exception.InnerException.Message ?? $_.Exception.Message)") } } $entry.Dispose() } catch { $errors.Add("LDAPConnect: $($_.Exception.Message)") } [PSCustomObject]@{ User = "$sam ($sid)" Success = ($errors.Count -eq 0) Errors = ($errors -join " | ") } } # ---- PARALLEL JOB ---- $job = $users | ForEach-Object -Parallel $manipulateWorker -ThrottleLimit $ThrottleLimit -AsJob $allResults = [System.Collections.Generic.List[object]]::new() $processed = 0 $metricsRefreshEverySeconds = 3 $lastMetricsTime = [DateTime]::MinValue # force immediate first poll $lastValidCpu = $null $metricsJob = $null $progressTick = 0 $pulseChars = @('|', '/', '-', '\\') $metrics = [PSCustomObject]@{ CPU = 'n/a'; LDAP = 'n/a' } # Metrics worker runs as background job — never blocks the monitor loop $metricsWorker = { param($srv) $cpu = 'n/a' try { $c = (Get-Counter -ComputerName $srv '\Processor(_Total)\% Processor Time' -MaxSamples 1 -ErrorAction Stop).CounterSamples[0].CookedValue $cpu = [math]::Round($c, 1) } catch { try { $s = Get-CimInstance -ClassName Win32_Processor -ComputerName $srv -OperationTimeoutSec 3 -ErrorAction Stop | Select-Object -ExpandProperty LoadPercentage if ($s) { $cpu = [math]::Round(($s | Measure-Object -Average).Average, 1) } } catch {} } $lat = 'n/a' try { $sw = [System.Diagnostics.Stopwatch]::StartNew() $d = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$srv/RootDSE") $null = $d.Properties["defaultNamingContext"].Value $sw.Stop() $lat = $sw.ElapsedMilliseconds } catch {} [pscustomobject]@{ CPU = $cpu; LDAP = $lat } } # ---- MONITOR LOOP ---- while ($job.State -eq "Running") { if ([Console]::KeyAvailable) { if ([Console]::ReadKey($true).Key -eq 'Q') { Stop-Job $job break } } $newResults = Receive-Job $job if ($null -ne $newResults) { foreach ($r in @($newResults)) { $allResults.Add($r) } } $processed = $allResults.Count $elapsed = (Get-Date) - $startTime $rate = if ($elapsed.TotalSeconds -gt 0) { [math]::Round($processed / $elapsed.TotalSeconds, 2) } else { 0 } $eta = if ($rate -gt 0) { [math]::Round(($total - $processed) / $rate, 1) } else { 0 } # Non-blocking async metrics if ($null -ne $metricsJob) { if ($metricsJob.State -ne 'Running') { $fresh = Receive-Job $metricsJob Remove-Job $metricsJob -Force $metricsJob = $null if ($null -ne $fresh) { if ($fresh.CPU -is [ValueType]) { $lastValidCpu = $fresh.CPU } $metrics = [PSCustomObject]@{ CPU = if ($null -ne $lastValidCpu) { $lastValidCpu } else { $fresh.CPU } LDAP = $fresh.LDAP } } } elseif (((Get-Date) - $lastMetricsTime).TotalSeconds -gt 5) { Stop-Job $metricsJob Remove-Job $metricsJob -Force $metricsJob = $null } } if (($null -eq $metricsJob) -and (((Get-Date) - $lastMetricsTime).TotalSeconds -ge $metricsRefreshEverySeconds)) { $metricsJob = Start-Job -ScriptBlock $metricsWorker -ArgumentList $Server $lastMetricsTime = Get-Date } $pulse = $pulseChars[$progressTick % $pulseChars.Count] $progressTick++ $cpuStatus = if ($metrics.CPU -is [ValueType]) { "$($metrics.CPU)%" } else { $metrics.CPU } $ldapStatus = if ($metrics.LDAP -is [ValueType]) { "$($metrics.LDAP)ms" } else { $metrics.LDAP } $percent = [math]::Round(($processed / $total) * 100, 2) Write-Progress ` -Activity "Performing User Manipulation (PwReset + Disable + AdminDesc)" ` -Status "$pulse Done: $processed/$total | $rate ops/s | ETA: $eta s | Throttle: $ThrottleLimit | DC CPU: $cpuStatus | LDAP: $ldapStatus" ` -PercentComplete $percent Start-Sleep -Milliseconds 250 } # Cleanup metrics job if ($null -ne $metricsJob) { Stop-Job $metricsJob -ErrorAction SilentlyContinue Remove-Job $metricsJob -Force -ErrorAction SilentlyContinue } # Collect remaining results $remainingResults = Receive-Job $job if ($null -ne $remainingResults) { foreach ($r in @($remainingResults)) { $allResults.Add($r) } } Remove-Job $job -Force Write-Progress -Activity "Performing User Manipulation (PwReset + Disable + AdminDesc)" -Completed # ---- RESULTS ---- $succeeded = $allResults | Where-Object { $_.Success -eq $true } $failed = $allResults | Where-Object { $_.Success -eq $false } if ($succeeded) { Invoke-Output -Type TextMaker -Message "Successfully manipulated user accounts in the selected scope: $cname | " -TM "Total: $($succeeded.Count)" } Undo-UserManipulation -TickValue $TickValue -Server $server -SearchBase $SearchBase if ($failed) { Invoke-Output -Type Warning -Message "$($failed.Count) user(s) had errors:" $failed | ForEach-Object { Invoke-Output -Type Bullet -Message "$($_.User): $($_.Errors)" } } Invoke-Output -Type Success -Message "Completed user account manipulation for the current scope (see examples above)." Write-Log -Message " >> Password used: $NewPassword" Write-Log -Message " >> TickValue: $TickValue" ######################## main code ############################ $runtime = Get-RunTime -StartRunTime $StartRunTime Write-Log -Message " Run Time: $runtime [h] ###" Write-Log -Message "### End Function $CurrentFunction ###" } |