321 lines
11 KiB
PowerShell
321 lines
11 KiB
PowerShell
# ============================================================
|
|
# 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()
|
|
}
|