############################################################################### #### windows-ad-smoke-tests.ps1 — Verify Active Directory health #### #### Checks DC connectivity, LDAP, DNS, replication, FSMO, shares, #### #### Kerberos, Group Policy, ADWS, trusts, and time sync. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-ad-smoke-tests.ps1 #### #### .\windows-ad-smoke-tests.ps1 -DomainController DC01 #### #### .\windows-ad-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string]$DomainController = "", [string]$Domain = "", [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-ad-smoke-tests.ps1 [OPTIONS] Smoke-test Active Directory infrastructure. PowerShell 5.1+. Designed for domain-joined Windows machines. Parameters: -DomainController DC Target DC hostname or IP (default: auto-detect via nltest) -Domain FQDN AD domain FQDN (default: auto-detect via USERDNSDOMAIN) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-ad-smoke-tests.ps1 .\windows-ad-smoke-tests.ps1 -DomainController DC01.corp.local .\windows-ad-smoke-tests.ps1 -Domain corp.local -OutputFormat tap .\windows-ad-smoke-tests.ps1 -NoColor -Verbose "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } # ============================================================================ # AUTO-DETECTION # ============================================================================ function Resolve-DomainController { if ($DomainController) { Write-Verbose "Using specified DC: $DomainController" return $DomainController } try { $nltest = nltest /dsgetdc: 2>&1 $dcLine = $nltest | Where-Object { $_ -match "DC: \\\\" } if ($dcLine -match "DC: \\\\(.+)$") { $detected = $Matches[1].Trim() Write-Verbose "Auto-detected DC: $detected" return $detected } } catch {} try { $dcLocator = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain() $dc = $dcLocator.FindDomainController() if ($dc.Name) { Write-Verbose "Auto-detected DC via .NET: $($dc.Name)" return $dc.Name } } catch {} Write-Err "Cannot auto-detect domain controller. Use -DomainController parameter." exit 1 } function Resolve-Domain { if ($Domain) { Write-Verbose "Using specified domain: $Domain" return $Domain } if ($env:USERDNSDOMAIN) { Write-Verbose "Auto-detected domain: $($env:USERDNSDOMAIN)" return $env:USERDNSDOMAIN } try { $dom = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain() if ($dom.Name) { Write-Verbose "Auto-detected domain via .NET: $($dom.Name)" return $dom.Name } } catch {} Write-Err "Cannot auto-detect domain. Use -Domain parameter." exit 1 } # ============================================================================ # TESTS # ============================================================================ # -- 1. DC Connectivity ---------------------------------------------------- function Test-DCConnectivity { Write-Section "Connectivity" try { $ping = Test-Connection -ComputerName $script:DC -Count 2 -Quiet -ErrorAction Stop if ($ping) { Record-Pass "DC connectivity" $script:DC } else { Record-Fail "DC connectivity" "$($script:DC) - no response" } } catch { Record-Fail "DC connectivity" "$($script:DC) - $($_.Exception.Message)" } } # -- 2. LDAP Bind ----------------------------------------------------------- function Test-LDAPBind { Write-Section "LDAP" try { $ldapPath = "LDAP://$($script:DC)" $entry = New-Object System.DirectoryServices.DirectoryEntry($ldapPath) $searcher = New-Object System.DirectoryServices.DirectorySearcher($entry) $searcher.SearchScope = "Base" $searcher.PropertiesToLoad.Add("defaultNamingContext") | Out-Null $result = $searcher.FindOne() if ($result) { $nc = $result.Properties["defaultnamingcontext"][0] Record-Pass "LDAP bind" "$($script:DC) - $nc" } else { Record-Fail "LDAP bind" "$($script:DC) - bind succeeded but no result" } $searcher.Dispose() $entry.Dispose() } catch { Record-Fail "LDAP bind" "$($script:DC) - $($_.Exception.Message)" } } # -- 3. LDAPS Connectivity -------------------------------------------------- function Test-LDAPS { try { $tcp = Test-NetConnection -ComputerName $script:DC -Port 636 -WarningAction SilentlyContinue -ErrorAction Stop if ($tcp.TcpTestSucceeded) { Record-Pass "LDAPS connectivity" "$($script:DC):636" } else { Record-Fail "LDAPS connectivity" "$($script:DC):636 - port closed" } } catch { if (Test-CommandExists "Test-NetConnection") { Record-Fail "LDAPS connectivity" "$($script:DC):636 - $($_.Exception.Message)" } else { Record-Skip "LDAPS connectivity" "Test-NetConnection not available" } } } # -- 4. DNS SRV Records ---------------------------------------------------- function Test-DNSSrvRecords { Write-Section "DNS" $srvRecord = "_ldap._tcp.dc._msdcs.$($script:DomainFQDN)" try { if (Test-CommandExists "Resolve-DnsName") { $results = Resolve-DnsName -Name $srvRecord -Type SRV -ErrorAction Stop if ($results) { $count = ($results | Where-Object { $_.QueryType -eq "SRV" }).Count Record-Pass "DNS SRV records" "$srvRecord - $count record(s)" } else { Record-Fail "DNS SRV records" "$srvRecord - no records returned" } } else { $nslookup = nslookup -type=srv $srvRecord 2>&1 if ($nslookup -match "svr hostname") { Record-Pass "DNS SRV records" $srvRecord } else { Record-Fail "DNS SRV records" "$srvRecord - lookup failed" } } } catch { Record-Fail "DNS SRV records" "$srvRecord - $($_.Exception.Message)" } } # -- 5. AD Replication ------------------------------------------------------ function Test-ADReplication { Write-Section "Replication" $adModuleLoaded = Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue if (-not $adModuleLoaded) { try { Import-Module ActiveDirectory -ErrorAction Stop } catch {} $adModuleLoaded = Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue } if ($adModuleLoaded) { try { $partners = Get-ADReplicationPartnerMetadata -Target $script:DC -ErrorAction Stop $failures = $partners | Where-Object { $_.ConsecutiveReplicationFailures -gt 0 } if ($failures) { $failCount = ($failures | Measure-Object).Count Record-Fail "AD replication status" "$failCount partner(s) with failures" } else { $partnerCount = ($partners | Measure-Object).Count Record-Pass "AD replication status" "$partnerCount partner(s), no failures" } return } catch { Write-Verbose "Get-ADReplicationPartnerMetadata failed: $($_.Exception.Message)" } } if (Test-CommandExists "repadmin") { try { $output = repadmin /replsummary $script:DC 2>&1 | Out-String if ($output -match "failed") { Record-Fail "AD replication status" "repadmin reports failures" } elseif ($output -match "Source DSA") { Record-Pass "AD replication status" "repadmin reports no failures" } else { Record-Pass "AD replication status" "repadmin completed" } } catch { Record-Fail "AD replication status" "repadmin error - $($_.Exception.Message)" } } else { Record-Skip "AD replication status" "neither AD module nor repadmin available" } } # -- 6. FSMO Roles ---------------------------------------------------------- function Test-FSMORoles { Write-Section "FSMO" if (Test-CommandExists "netdom") { try { $output = netdom query fsmo 2>&1 | Out-String $roles = @( "Schema master", "Domain naming master", "PDC", "RID pool manager", "Infrastructure master" ) $found = 0 foreach ($role in $roles) { if ($output -match "$role\s+\S+") { $found++ } } if ($found -eq 5) { Record-Pass "FSMO roles assigned" "all 5 roles located" } elseif ($found -gt 0) { Record-Fail "FSMO roles assigned" "only $found of 5 roles found" } else { Record-Fail "FSMO roles assigned" "no roles found in netdom output" } } catch { Record-Fail "FSMO roles assigned" "netdom error - $($_.Exception.Message)" } } else { $adModuleLoaded = Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue if ($adModuleLoaded) { try { $forest = Get-ADForest -ErrorAction Stop $domain = Get-ADDomain -ErrorAction Stop $roleCount = 0 if ($forest.SchemaMaster) { $roleCount++ } if ($forest.DomainNamingMaster) { $roleCount++ } if ($domain.PDCEmulator) { $roleCount++ } if ($domain.RIDMaster) { $roleCount++ } if ($domain.InfrastructureMaster) { $roleCount++ } if ($roleCount -eq 5) { Record-Pass "FSMO roles assigned" "all 5 roles located via AD module" } else { Record-Fail "FSMO roles assigned" "only $roleCount of 5 roles found" } } catch { Record-Fail "FSMO roles assigned" "AD module error - $($_.Exception.Message)" } } else { Record-Skip "FSMO roles assigned" "neither netdom nor AD module available" } } } # -- 7. SYSVOL Share -------------------------------------------------------- function Test-SYSVOLShare { Write-Section "Shares" $sysvolPath = "\\$($script:DC)\SYSVOL" try { if (Test-Path $sysvolPath -ErrorAction Stop) { Record-Pass "SYSVOL share accessible" $sysvolPath } else { Record-Fail "SYSVOL share accessible" "$sysvolPath - not accessible" } } catch { Record-Fail "SYSVOL share accessible" "$sysvolPath - $($_.Exception.Message)" } } # -- 8. NETLOGON Share ------------------------------------------------------ function Test-NETLOGONShare { $netlogonPath = "\\$($script:DC)\NETLOGON" try { if (Test-Path $netlogonPath -ErrorAction Stop) { Record-Pass "NETLOGON share accessible" $netlogonPath } else { Record-Fail "NETLOGON share accessible" "$netlogonPath - not accessible" } } catch { Record-Fail "NETLOGON share accessible" "$netlogonPath - $($_.Exception.Message)" } } # -- 9. Kerberos ----------------------------------------------------------- function Test-Kerberos { Write-Section "Authentication" try { $secure = Test-ComputerSecureChannel -ErrorAction Stop if ($secure) { Record-Pass "Kerberos secure channel" "secure channel OK" } else { Record-Fail "Kerberos secure channel" "secure channel broken" } } catch { if (Test-CommandExists "klist") { try { $klist = klist 2>&1 | Out-String if ($klist -match "krbtgt") { Record-Pass "Kerberos secure channel" "TGT present (klist)" } else { Record-Fail "Kerberos secure channel" "no TGT found (klist)" } } catch { Record-Fail "Kerberos secure channel" "klist error - $($_.Exception.Message)" } } else { Record-Fail "Kerberos secure channel" $_.Exception.Message } } } # -- 10. Group Policy ------------------------------------------------------- function Test-GroupPolicy { if (Test-CommandExists "gpresult") { try { $output = gpresult /r 2>&1 | Out-String if ($output -match "Applied Group Policy Objects" -or $output -match "Last time Group Policy was applied") { $lastApplied = "" if ($output -match "Last time Group Policy was applied:\s*(.+)") { $lastApplied = $Matches[1].Trim() } Record-Pass "Group Policy processing" $(if ($lastApplied) { "last applied: $lastApplied" } else { "policy applied" }) } elseif ($output -match "ERROR\b|Access is denied") { Record-Fail "Group Policy processing" "gpresult returned an error" } else { Record-Fail "Group Policy processing" "could not confirm policy application" } } catch { Record-Fail "Group Policy processing" "gpresult error - $($_.Exception.Message)" } } else { Record-Skip "Group Policy processing" "gpresult not available" } } # -- 11. AD Web Services (ADWS) -------------------------------------------- function Test-ADWS { Write-Section "Services" try { $tcp = Test-NetConnection -ComputerName $script:DC -Port 9389 -WarningAction SilentlyContinue -ErrorAction Stop if ($tcp.TcpTestSucceeded) { Record-Pass "AD Web Services" "$($script:DC):9389" } else { Record-Fail "AD Web Services" "$($script:DC):9389 - port closed" } } catch { if (Test-CommandExists "Test-NetConnection") { Record-Fail "AD Web Services" "$($script:DC):9389 - $($_.Exception.Message)" } else { Record-Skip "AD Web Services" "Test-NetConnection not available" } } } # -- 12. Domain Trusts ------------------------------------------------------ function Test-DomainTrusts { Write-Section "Trusts" if (Test-CommandExists "nltest") { try { $output = nltest /trusted_domains 2>&1 | Out-String if ($output -match "The command completed successfully") { $trustLines = ($output -split "`n") | Where-Object { $_ -match "\S" -and $_ -notmatch "The command completed" -and $_ -notmatch "^$" } $trustCount = ($trustLines | Measure-Object).Count Record-Pass "Domain trusts" "$trustCount trust(s) enumerated" } elseif ($output -match "ERROR_NO_SUCH_DOMAIN") { Record-Fail "Domain trusts" "domain not found" } else { Record-Fail "Domain trusts" "nltest failed" } } catch { Record-Fail "Domain trusts" "nltest error - $($_.Exception.Message)" } } else { $adModuleLoaded = Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue if ($adModuleLoaded) { try { $trusts = Get-ADTrust -Filter * -ErrorAction Stop $trustCount = ($trusts | Measure-Object).Count Record-Pass "Domain trusts" "$trustCount trust(s) found" } catch { Record-Fail "Domain trusts" "Get-ADTrust error - $($_.Exception.Message)" } } else { Record-Skip "Domain trusts" "neither nltest nor AD module available" } } } # -- 13. Time Sync ---------------------------------------------------------- function Test-TimeSync { Write-Section "Time" if (Test-CommandExists "w32tm") { try { $output = w32tm /monitor /computers:$($script:DC) /nowarn 2>&1 | Out-String if ($output -match "NTP:\s*([+-]?\d+\.\d+)s") { $skew = [math]::Abs([double]$Matches[1]) $skewStr = "{0:N2}" -f $skew if ($skew -le 5.0) { Record-Pass "Time sync" "skew ${skewStr}s against $($script:DC)" } else { Record-Fail "Time sync" "skew ${skewStr}s exceeds 5s threshold" } } elseif ($output -match "error|timeout|unreachable" ) { Record-Fail "Time sync" "w32tm could not reach $($script:DC)" } else { Record-Pass "Time sync" "w32tm completed against $($script:DC)" } } catch { Record-Fail "Time sync" "w32tm error - $($_.Exception.Message)" } } else { Record-Skip "Time sync" "w32tm not available" } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows AD Smoke Tests" "White" Write-Host "DC: $($script:DC)" Write-Host "Domain: $($script:DomainFQDN)" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $($script:DC) ($($script:DomainFQDN))" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ # Resolve targets $script:DC = Resolve-DomainController $script:DomainFQDN = Resolve-Domain Write-Header # Run all tests Test-DCConnectivity Test-LDAPBind Test-LDAPS Test-DNSSrvRecords Test-ADReplication Test-FSMORoles Test-SYSVOLShare Test-NETLOGONShare Test-Kerberos Test-GroupPolicy Test-ADWS Test-DomainTrusts Test-TimeSync Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }