# ============================================================ # Get-ServersFromOU.ps1 # ============================================================ # Queries Active Directory for all enabled Windows Server # computer objects under a specified OU. # Resolves hostnames to IPs at runtime via DNS (does NOT rely # on AD's IPv4Address attribute which is often stale). # ============================================================ function Get-ServersFromOU { <# .SYNOPSIS Returns all enabled Windows Server computer objects from an AD OU. .DESCRIPTION Uses Get-ADComputer to enumerate computers in the target OU. Filters to Windows Server OS only (excludes workstations/other). Resolves each hostname to an IP at runtime using DNS. Servers that cannot be resolved are flagged as Unresolvable but still returned so they appear in the CSV report. .PARAMETER OUPath Distinguished name of the OU to search. Example: "OU=Servers,OU=Datacenter,DC=corp,DC=local" .PARAMETER Recurse If set, searches all sub-OUs under OUPath. Default: $true .OUTPUTS Array of PSCustomObjects: @{ Hostname; ResolvedIP; OS; OperatingSystem; Reachable } .EXAMPLE Get-ServersFromOU -OUPath "OU=Servers,DC=corp,DC=local" #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$OUPath, [switch]$NoRecurse ) # Verify RSAT / ActiveDirectory module is available if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) { throw "ActiveDirectory PowerShell module not found. Install RSAT: " + "Install-WindowsFeature RSAT-AD-PowerShell (Server) or " + "Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0 (Workstation)" } Import-Module ActiveDirectory -ErrorAction Stop Write-Host " Querying AD OU: $OUPath" -ForegroundColor Cyan $searchScope = if ($NoRecurse) { "OneLevel" } else { "Subtree" } try { $computers = Get-ADComputer -Filter { Enabled -eq $true } ` -SearchBase $OUPath ` -SearchScope $searchScope ` -Properties Name, OperatingSystem, OperatingSystemVersion, DNSHostName ` -ErrorAction Stop } catch { throw "Failed to query AD OU '$OUPath': $($_.Exception.Message)" } if ($null -eq $computers -or @($computers).Count -eq 0) { Write-Warning "No enabled computer objects found in OU: $OUPath" return @() } # Filter to Windows Server OS only $serverComputers = $computers | Where-Object { $_.OperatingSystem -like "*Windows Server*" } $nonServerCount = @($computers).Count - @($serverComputers).Count if ($nonServerCount -gt 0) { Write-Verbose "Excluded $nonServerCount non-server OS objects from results" } Write-Host " Found $(@($serverComputers).Count) Windows Server object(s) in OU" -ForegroundColor Cyan $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($computer in $serverComputers) { # Prefer DNSHostName from AD, fall back to Name $hostname = if ($computer.DNSHostName) { $computer.DNSHostName } else { $computer.Name } # Resolve IP at runtime — more reliable than AD's IPv4Address attribute $resolvedIP = Resolve-ADHostnameToIP -Hostname $hostname $reachable = $resolvedIP -ne "Unresolvable" if (-not $reachable) { Write-Warning " [$hostname] Cannot resolve hostname — will be flagged in report" } else { Write-Verbose " [$hostname] Resolved to $resolvedIP" } $results.Add([PSCustomObject]@{ Hostname = $hostname ResolvedIP = $resolvedIP OS = "Windows" OperatingSystem = $computer.OperatingSystem OSVersion = $computer.OperatingSystemVersion Reachable = $reachable Source = "AD" }) } Write-Host " Resolved: $(@($results | Where-Object Reachable).Count) | Unresolvable: $(@($results | Where-Object { -not $_.Reachable }).Count)" -ForegroundColor Cyan return $results.ToArray() } function Resolve-ADHostnameToIP { <# .SYNOPSIS Resolves a hostname to its first IPv4 address via DNS. Returns "Unresolvable" if DNS lookup fails. #> param([string]$Hostname) # Already an IP — return as-is if ($Hostname -match '^\d{1,3}(\.\d{1,3}){3}$') { return $Hostname } try { $addresses = [System.Net.Dns]::GetHostAddresses($Hostname) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } if ($addresses) { return ($addresses | Select-Object -First 1).IPAddressToString } return "Unresolvable" } catch { return "Unresolvable" } } function ConvertFrom-ServerListFile { <# .SYNOPSIS Parses a CSV or TXT server list file into a normalized server object array. .DESCRIPTION Supports two formats: CSV: Columns Hostname, OS, Distro (lines starting with # are comments) TXT: One hostname per line (lines starting with # are comments) OS and Distro will be auto-detected at runtime. .PARAMETER Path Path to the CSV or TXT file. .OUTPUTS Array of PSCustomObjects: @{ Hostname; ResolvedIP; OS; Distro; OperatingSystem; Reachable; Source } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path $Path)) { throw "Server list file not found: $Path" } $extension = [System.IO.Path]::GetExtension($Path).ToLower() $results = [System.Collections.Generic.List[PSCustomObject]]::new() if ($extension -eq ".csv") { # Read CSV, skip comment lines $rawLines = Get-Content $Path | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne "" } # Re-parse as CSV from filtered lines $csvData = $rawLines | ConvertFrom-Csv foreach ($row in $csvData) { $hostname = $row.Hostname.Trim() if ([string]::IsNullOrWhiteSpace($hostname)) { continue } $os = if ($row.PSObject.Properties.Name -contains "OS") { $row.OS.Trim() } else { "Unknown" } $distro = if ($row.PSObject.Properties.Name -contains "Distro") { $row.Distro.Trim() } else { "" } $resolvedIP = Resolve-ADHostnameToIP -Hostname $hostname $reachable = $resolvedIP -ne "Unresolvable" if (-not $reachable) { Write-Warning " [$hostname] Cannot resolve hostname" } $results.Add([PSCustomObject]@{ Hostname = $hostname ResolvedIP = $resolvedIP OS = $os Distro = $distro OperatingSystem = "" OSVersion = "" Reachable = $reachable Source = "File:$Path" }) } } else { # TXT format — one hostname per line $lines = Get-Content $Path | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne "" } foreach ($line in $lines) { $hostname = $line.Trim() if ([string]::IsNullOrWhiteSpace($hostname)) { continue } $resolvedIP = Resolve-ADHostnameToIP -Hostname $hostname $reachable = $resolvedIP -ne "Unresolvable" if (-not $reachable) { Write-Warning " [$hostname] Cannot resolve hostname" } $results.Add([PSCustomObject]@{ Hostname = $hostname ResolvedIP = $resolvedIP OS = "Unknown" # Will be auto-detected at connection time Distro = "" OperatingSystem = "" OSVersion = "" Reachable = $reachable Source = "File:$Path" }) } } Write-Host " Parsed $(@($results).Count) server(s) from file: $Path" -ForegroundColor Cyan return $results.ToArray() } function ConvertFrom-InlineServerList { <# .SYNOPSIS Converts an inline array of hostnames into a normalized server object array. .DESCRIPTION Accepts a comma-separated string or a string array. OS and Distro will be auto-detected at connection time. .PARAMETER Servers Array of hostnames or a single comma-separated string. .OUTPUTS Array of PSCustomObjects with same schema as ConvertFrom-ServerListFile. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Servers ) # Handle comma-separated single string input if ($Servers.Count -eq 1 -and $Servers[0] -match ",") { $Servers = $Servers[0] -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } } $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($hostname in $Servers) { $hostname = $hostname.Trim() if ([string]::IsNullOrWhiteSpace($hostname)) { continue } $resolvedIP = Resolve-ADHostnameToIP -Hostname $hostname $reachable = $resolvedIP -ne "Unresolvable" if (-not $reachable) { Write-Warning " [$hostname] Cannot resolve hostname" } $results.Add([PSCustomObject]@{ Hostname = $hostname ResolvedIP = $resolvedIP OS = "Unknown" # Auto-detected at connection time Distro = "" OperatingSystem = "" OSVersion = "" Reachable = $reachable Source = "Inline" }) } Write-Host " Parsed $(@($results).Count) server(s) from inline list" -ForegroundColor Cyan return $results.ToArray() } function Merge-ServerLists { <# .SYNOPSIS Merges multiple server lists, deduplicating by Hostname (case-insensitive). .DESCRIPTION When -OUPath and -ServerFile are both provided, this function unions the two lists. The first occurrence of a hostname wins. .PARAMETER Lists One or more arrays of server objects to merge. .OUTPUTS Deduplicated array of server objects. #> param( [Parameter(Mandatory)] [object[][]]$Lists ) $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $merged = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($list in $Lists) { foreach ($server in $list) { if ($seen.Add($server.Hostname)) { $merged.Add($server) } else { Write-Verbose "Duplicate hostname '$($server.Hostname)' from $($server.Source) — skipped" } } } return $merged.ToArray() }