############################################################################### #### windows-iis-smoke-tests.ps1 — Verify IIS health #### #### Checks W3SVC, WAS, app pools, site responses, SSL certs, #### #### bindings, ARR, URL Rewrite, logs, FREB, and event log. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-iis-smoke-tests.ps1 #### #### .\windows-iis-smoke-tests.ps1 -SiteNames "Default Web Site" #### #### .\windows-iis-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string[]]$SiteNames = @(), [int]$CertExpiryDays = 30, [string]$TestUrl = "", [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-iis-smoke-tests.ps1 [OPTIONS] Smoke-test IIS (Internet Information Services). PowerShell 5.1+. Requires the WebAdministration module (IIS Management Tools). Parameters: -SiteNames "Site1","Site2" Sites to test (default: all sites) -CertExpiryDays N Warn if cert expires within N days (default: 30) -TestUrl URL Optional specific URL to test -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-iis-smoke-tests.ps1 .\windows-iis-smoke-tests.ps1 -SiteNames "Default Web Site","IntranetApp" .\windows-iis-smoke-tests.ps1 -CertExpiryDays 60 -OutputFormat tap .\windows-iis-smoke-tests.ps1 -TestUrl "https://intranet.corp.local/health" .\windows-iis-smoke-tests.ps1 -NoColor "@ 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 Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } function Get-TargetSites { if ($SiteNames.Count -gt 0) { return @(Get-Website | Where-Object { $SiteNames -contains $_.Name }) } return @(Get-Website) } # ============================================================================ # TESTS # ============================================================================ # -- 1. W3SVC Service ------------------------------------------------------- function Test-W3SVC { Write-Section "Services" try { $svc = Get-Service -Name W3SVC -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "W3SVC service running" } else { Record-Fail "W3SVC service running" "status: $($svc.Status)" } } catch { Record-Fail "W3SVC service running" "service not found" } } # -- 2. WAS Service ---------------------------------------------------------- function Test-WAS { try { $svc = Get-Service -Name WAS -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "WAS service running" } else { Record-Fail "WAS service running" "status: $($svc.Status)" } } catch { Record-Fail "WAS service running" "service not found" } } # -- 3. Application Pool States --------------------------------------------- function Test-AppPoolStates { Write-Section "Application Pools" try { $pools = @(Get-WebAppPoolState -ErrorAction Stop) if ($pools.Count -eq 0) { Record-Skip "application pools" "no pools found" return } $started = @($pools | Where-Object { $_.Value -eq "Started" }) $stopped = @($pools | Where-Object { $_.Value -ne "Started" }) foreach ($p in $started) { $poolName = ($p.ItemXPath -replace ".*@name='([^']+)'.*", '$1') Record-Pass "app pool started" $poolName } foreach ($p in $stopped) { $poolName = ($p.ItemXPath -replace ".*@name='([^']+)'.*", '$1') Record-Fail "app pool stopped" "$poolName state: $($p.Value)" } } catch { Record-Fail "application pools" $_.Exception.Message } } # -- 4. Site HTTP Responses -------------------------------------------------- function Test-SiteResponses { Write-Section "Site Responses" $sites = Get-TargetSites if ($sites.Count -eq 0) { Record-Skip "site responses" "no sites found" return } foreach ($site in $sites) { if ($site.State -ne "Started") { Record-Fail "site responds ($($site.Name))" "site not started" continue } $bindings = @($site.Bindings.Collection) if ($bindings.Count -eq 0) { Record-Skip "site responds ($($site.Name))" "no bindings" continue } $binding = $bindings[0] $proto = $binding.protocol $info = $binding.bindingInformation -split ":" $port = if ($info.Count -ge 2) { $info[1] } else { if ($proto -eq "https") { "443" } else { "80" } } $host_ = if ($info.Count -ge 3 -and $info[2]) { $info[2] } else { "localhost" } $url = "${proto}://${host_}:${port}/" try { $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop Record-Pass "site responds ($($site.Name))" "$url HTTP $($resp.StatusCode)" } catch { $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { "timeout" } if ($code -ge 200 -and $code -lt 500) { Record-Pass "site responds ($($site.Name))" "$url HTTP $code" } else { Record-Fail "site responds ($($site.Name))" "$url HTTP $code" } } } } # -- 5. SSL Certificate Validation ------------------------------------------ function Test-SSLCerts { Write-Section "SSL Certificates" $sites = Get-TargetSites $httpsBindings = @() foreach ($site in $sites) { foreach ($b in $site.Bindings.Collection) { if ($b.protocol -eq "https" -and $b.certificateHash) { $httpsBindings += [PSCustomObject]@{ SiteName = $site.Name Hash = $b.certificateHash Store = if ($b.certificateStoreName) { $b.certificateStoreName } else { "My" } Binding = $b.bindingInformation } } } } if ($httpsBindings.Count -eq 0) { Record-Skip "SSL certificates" "no HTTPS bindings found" return } $checked = @{} foreach ($hb in $httpsBindings) { if ($checked.ContainsKey($hb.Hash)) { continue } $checked[$hb.Hash] = $true try { $cert = Get-ChildItem "Cert:\LocalMachine\$($hb.Store)\$($hb.Hash)" -ErrorAction Stop $daysLeft = [math]::Floor(($cert.NotAfter - (Get-Date)).TotalDays) $cn = $cert.Subject -replace "CN=", "" -replace ",.*", "" if ($cert.NotAfter -lt (Get-Date)) { Record-Fail "SSL certificate ($cn)" "EXPIRED on $($cert.NotAfter.ToString('yyyy-MM-dd'))" } elseif ($daysLeft -lt $CertExpiryDays) { Record-Fail "certificate expiry ($cn)" "$daysLeft days remaining (threshold: $CertExpiryDays)" } else { Record-Pass "SSL certificate valid ($cn)" "expires $($cert.NotAfter.ToString('yyyy-MM-dd')), $daysLeft days" } } catch { Record-Fail "SSL certificate ($($hb.SiteName))" "cert hash $($hb.Hash) not found in $($hb.Store) store" } } } # -- 6. Site Bindings -------------------------------------------------------- function Test-SiteBindings { Write-Section "Configuration" $sites = Get-TargetSites $totalBindings = 0 $validBindings = 0 foreach ($site in $sites) { foreach ($b in $site.Bindings.Collection) { $totalBindings++ $info = $b.bindingInformation $proto = $b.protocol if ($proto -in @("http","https") -and $info -match "^\*?:?\d+:") { $validBindings++ } elseif ($proto -in @("net.tcp","net.pipe","net.msmq","msmq.formatname")) { $validBindings++ } } } if ($totalBindings -eq 0) { Record-Skip "site bindings" "no bindings configured" } elseif ($validBindings -eq $totalBindings) { Record-Pass "site bindings valid" "$($sites.Count) sites, $totalBindings bindings" } else { $invalid = $totalBindings - $validBindings Record-Fail "site bindings" "$invalid invalid out of $totalBindings bindings" } } # -- 7. Default Document ---------------------------------------------------- function Test-DefaultDocument { $sites = Get-TargetSites if ($sites.Count -eq 0) { return } foreach ($site in $sites) { try { $docs = Get-WebConfiguration -Filter "system.webServer/defaultDocument/files/add" -PSPath "IIS:\Sites\$($site.Name)" -ErrorAction Stop if ($docs -and @($docs).Count -gt 0) { $first = @($docs)[0].value Record-Pass "default document ($($site.Name))" $first } else { Record-Fail "default document ($($site.Name))" "none configured" } } catch { Record-Skip "default document ($($site.Name))" "could not read config" } } } # -- 8. Custom URL Test ----------------------------------------------------- function Test-CustomUrl { if (-not $TestUrl) { return } Write-Section "Custom URL" try { $resp = Invoke-WebRequest -Uri $TestUrl -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop Record-Pass "custom URL" "$TestUrl HTTP $($resp.StatusCode)" } catch { $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { "timeout" } Record-Fail "custom URL" "$TestUrl HTTP $code" } } # -- 9. ARR Health ----------------------------------------------------------- function Test-ARRHealth { Write-Section "Modules" try { $arr = Get-WebGlobalModule -Name "ApplicationRequestRouting" -ErrorAction Stop if (-not $arr) { Record-Skip "ARR health" "ARR not installed" return } } catch { Record-Skip "ARR health" "ARR not installed" return } try { $farms = Get-WebConfiguration -Filter "webFarms/webFarm" -PSPath "MACHINE/WEBROOT/APPHOST" -ErrorAction Stop if (-not $farms -or @($farms).Count -eq 0) { Record-Pass "ARR health" "ARR installed, no server farms configured" return } foreach ($farm in @($farms)) { $farmName = $farm.name $servers = @($farm.Collection) $healthy = @($servers | Where-Object { $_.enabled -eq $true }) if ($healthy.Count -eq $servers.Count) { Record-Pass "ARR farm ($farmName)" "$($servers.Count) servers enabled" } else { $disabled = $servers.Count - $healthy.Count Record-Fail "ARR farm ($farmName)" "$disabled of $($servers.Count) servers disabled" } } } catch { Record-Fail "ARR health" $_.Exception.Message } } # -- 10. URL Rewrite Module ------------------------------------------------- function Test-URLRewrite { try { $mod = Get-WebGlobalModule -Name "RewriteModule" -ErrorAction Stop if ($mod) { Record-Pass "URL Rewrite module installed" } else { Record-Skip "URL Rewrite module" "not installed" } } catch { Record-Skip "URL Rewrite module" "not installed" } } # -- 11. Log Directory ------------------------------------------------------- function Test-LogDirectory { Write-Section "Logging" try { $logDir = (Get-WebConfigurationProperty -Filter "system.applicationHost/sites/siteDefaults/logFile" -Name "directory" -ErrorAction Stop).Value $expanded = [System.Environment]::ExpandEnvironmentVariables($logDir) if (Test-Path $expanded) { try { $testFile = Join-Path $expanded ".iis-smoke-test-$(Get-Random).tmp" [IO.File]::WriteAllText($testFile, "test") Remove-Item $testFile -Force Record-Pass "log directory accessible" $expanded } catch { Record-Fail "log directory writable" "$expanded - not writable" } } else { Record-Fail "log directory exists" "$expanded - not found" } } catch { Record-Skip "log directory" "could not read log configuration" } } # -- 12. Failed Request Tracing ---------------------------------------------- function Test-FREB { $sites = Get-TargetSites $frebFound = $false foreach ($site in $sites) { try { $tracing = Get-WebConfiguration -Filter "system.webServer/tracing/traceFailedRequests" -PSPath "IIS:\Sites\$($site.Name)" -ErrorAction Stop if ($tracing -and @($tracing).Count -gt 0) { $frebFound = $true $frebDir = "$($site.TraceFailedRequestsLogging.Directory)" if ($frebDir -and (Test-Path ([System.Environment]::ExpandEnvironmentVariables($frebDir)))) { Record-Pass "Failed Request Tracing ($($site.Name))" "enabled, logs accessible" } else { Record-Pass "Failed Request Tracing ($($site.Name))" "enabled" } } } catch { } } if (-not $frebFound) { Record-Skip "Failed Request Tracing" "FREB not enabled on any site" } } # -- 13. Event Log ----------------------------------------------------------- function Test-EventLog { Write-Section "Event Log" try { $events = @() $sources = @("IIS", "W3SVC", "WAS", "Microsoft-Windows-IIS*") foreach ($src in $sources) { try { $filter = @{ LogName = "System","Application" Level = 2 StartTime = (Get-Date).AddHours(-24) } $found = @(Get-WinEvent -FilterHashtable $filter -ErrorAction SilentlyContinue | Where-Object { $_.ProviderName -like "*IIS*" -or $_.ProviderName -like "W3SVC" -or $_.ProviderName -like "WAS" }) $events += $found } catch { } } if ($events.Count -eq 0) { Record-Pass "event log clean" "0 IIS errors in last 24h" } else { $latest = $events[0] $msg = $latest.Message.Substring(0, [math]::Min(80, $latest.Message.Length)) Record-Fail "event log" "$($events.Count) IIS error(s) in 24h — latest: $msg" } } catch { if ($_.Exception.Message -match "No events were found") { Record-Pass "event log clean" "0 IIS errors in last 24h" } else { Record-Fail "event log" $_.Exception.Message } } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows IIS Smoke Tests" "White" Write-Host "Server: $env:COMPUTERNAME" 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 $env:COMPUTERNAME" "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 # ============================================================================ Write-Header # Load WebAdministration module try { Import-Module WebAdministration -ErrorAction Stop Write-Verbose "WebAdministration module loaded" } catch { Write-Err "WebAdministration module not available. Install IIS Management Tools." exit 1 } # Run all tests Test-W3SVC Test-WAS Test-AppPoolStates Test-SiteResponses Test-SSLCerts Test-SiteBindings Test-DefaultDocument Test-CustomUrl Test-ARRHealth Test-URLRewrite Test-LogDirectory Test-FREB Test-EventLog Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }