Public/Test-RsHttpsEndpoint.ps1
|
# ============================================================================= # Script : Public/Test-RsHttpsEndpoint.ps1 # Author : Keith Ramsey # ============================================================================= # Change Log # ----------------------------------------------------------------------------- # 2026-06-29 cross-suite meta Claude Phase 5.1 (DR-016): the FUNCTIONAL gate. # Verifies the RS site actually SERVES over HTTPS -- # not just that a WMI binding exists. Discovers the # URL (never assumes the path), GETs it (200/401 = # healthy), and confirms the served cert matches and # its SAN covers the host. Approach verified live # 2026-06-29 (HTTP 401, served-cert SAN matched). # ============================================================================= # Decision Contract: # - Never throws; returns SqlCert.Result (DR-005). # - Read-only probe -> no ShouldProcess. # - URL is DISCOVERED via Get-SqlRsRegisteredUrl (never hard-coded /ReportServer). # - For a wildcard reservation the host can't be discovered -> caller supplies # -VanityHost (the name users actually type; required to validate the SAN). # - 401 (RSWindowsNegotiate) is HEALTHY, not a failure; 503/refused/TLS = fail. # ============================================================================= function Test-RsHttpsEndpoint { <# .SYNOPSIS Confirms a Reporting Services endpoint actually serves over HTTPS. .DESCRIPTION The functional round-trip check that WMI metadata can't give you: a WMI binding can exist while the site is dead (SAN mismatch, service not restarted, broken reservation -> 503). This cmdlet DISCOVERS the real URL (via GetReportServerUrls), issues an HTTPS GET, and inspects the cert served on the wire. Healthy = TLS handshake succeeds AND HTTP status is 200 or 401 (401 is the normal RSWindowsNegotiate challenge -- the site is alive). It also confirms the served certificate's SAN covers the host and, if you pass -ExpectedThumbprint, that the served cert is the one you bound. Never throws. Returns a SqlCert.Result (Stage = Verify). .PARAMETER RsInstance RS instance name ('SSRS', 'PBIRS', or the SQL instance name). Default 'MSSQLSERVER'. .PARAMETER Application Endpoint to test: ReportServerWebService (default) or ReportServerWebApp. .PARAMETER Url Explicit full URL to test (overrides discovery), e.g. 'https://reports.example.com/ReportServer'. .PARAMETER VanityHost The host name users actually type. Required when the reservation is a strong wildcard ('+'), because the wildcard can't tell you the vanity name -- and testing localhost would not validate the cert SAN. When supplied, the discovered virtual-directory path is served under this host. .PARAMETER ExpectedThumbprint If supplied, the served cert thumbprint must match this (case-insensitive) or the result is Failed. .PARAMETER ComputerName Target computer for URL discovery. Defaults to the local machine. .PARAMETER Credential Credential for the CIM/WMI session on a remote or workgroup target. .OUTPUTS SqlCert.Result Data keys: Url, StatusCode, ServedThumbprint, ServedSan, SanCoversHost, ThumbprintMatch. .EXAMPLE Test-RsHttpsEndpoint -RsInstance 'PBIRS' -VanityHost 'reports.example.com' Discovers the web service path, GETs https://reports.example.com/<path>, and confirms it serves (200/401) with a cert whose SAN covers the vanity. .EXAMPLE Test-RsHttpsEndpoint -RsInstance 'SSRS' -Url 'https://reports.example.com/Reports' -ExpectedThumbprint $tp Tests the portal at an explicit URL and asserts the served cert matches $tp. .NOTES Steps: 1. Determine the URL to test: -Url wins; else discover the HTTPS URL for the Application via Get-SqlRsRegisteredUrl; if -VanityHost is given, serve the discovered path under that host. 2. If no URL can be determined, return Failed. 3. Probe via Invoke-RsHttpProbe (GET + served-cert capture). 4. If the probe got no HTTP status (connection/TLS failure), return Failed. 5. If status is not 200/401, return Failed (e.g. 503 = broken reservation). 6. If -ExpectedThumbprint given and the served cert doesn't match, Failed. 7. Confirm the served SAN covers the host (exact or wildcard); if not, Failed. 8. Else return Success with the probe details in Data. #> [CmdletBinding()] [OutputType('SqlCert.Result')] param( [string] $RsInstance = 'MSSQLSERVER', [ValidateSet('ReportServerWebService', 'ReportServerWebApp')] [string] $Application = 'ReportServerWebService', [string] $Url, [string] $VanityHost, [ValidatePattern('^[0-9A-Fa-f]{40}$')] [string] $ExpectedThumbprint, [string] $ComputerName = $env:COMPUTERNAME, [PSCredential] $Credential ) try { # Step 1 — determine the URL to test. $testUrl = $null if ($Url) { $testUrl = $Url } else { $regArgs = @{ InstanceName = $RsInstance; ComputerName = $ComputerName } if ($Credential) { $regArgs['Credential'] = $Credential } $registered = @(Get-SqlRsRegisteredUrl @regArgs) $httpsForApp = $registered | Where-Object { $_.Application -eq $Application -and $_.Url -like 'https:*' } | Select-Object -First 1 if ($httpsForApp) { if ($VanityHost) { # Serve the discovered path under the supplied vanity host. $p = [uri]$httpsForApp.Url $testUrl = "https://$VanityHost$($p.AbsolutePath)" } else { $testUrl = $httpsForApp.Url } } elseif ($VanityHost) { # No discovered HTTPS URL (e.g. wildcard only) -> derive the vdir # from config and serve it under the vanity host. $rsArgs = @{ InstanceName = $RsInstance; ComputerName = $ComputerName } if ($Credential) { $rsArgs['Credential'] = $Credential } $cfg = Get-SqlRsInstance @rsArgs $vdir = if ($cfg -and $Application -eq 'ReportServerWebApp') { $cfg.VirtualDirectoryReportManager } elseif ($cfg) { $cfg.VirtualDirectoryReportServer } else { $null } if ($vdir) { $testUrl = "https://$VanityHost/$vdir" } } } # Step 2 — nothing to test. if (-not $testUrl) { return New-SqlCertResult -Stage Verify -Status Failed ` -Detail "Could not determine an HTTPS URL to test for RS '$RsInstance' application '$Application'. Pass -Url, or -VanityHost (required when the reservation is a strong wildcard)." -Data $null } # Step 3 — probe. $probe = Invoke-RsHttpProbe -Url $testUrl $hostName = ([uri]$testUrl).Host $data = [pscustomobject]@{ Url = $testUrl StatusCode = $probe.StatusCode ServedThumbprint = $probe.ServedThumbprint ServedSan = $probe.ServedSan SanCoversHost = $false ThumbprintMatch = $null } # Step 4 — no HTTP response at all. if ($null -eq $probe.StatusCode) { return New-SqlCertResult -Stage Verify -Status Failed ` -Detail "No HTTP response from '$testUrl' (connection/TLS failure): $($probe.ProbeError)" -Data $data } # Step 5 — status must be healthy (200 or 401). if ($probe.StatusCode -notin 200, 401) { return New-SqlCertResult -Stage Verify -Status Failed ` -Detail "'$testUrl' returned HTTP $($probe.StatusCode) (200 or 401 expected; 503 usually means a broken URL reservation / app-domain)." -Data $data } # Step 6 — served cert must match the expected thumbprint, if given. if ($ExpectedThumbprint) { $match = ($probe.ServedThumbprint -ieq $ExpectedThumbprint) $data.ThumbprintMatch = $match if (-not $match) { return New-SqlCertResult -Stage Verify -Status Failed ` -Detail "Served cert thumbprint '$($probe.ServedThumbprint)' does not match expected '$ExpectedThumbprint' at '$testUrl'." -Data $data } } # Step 7 — served SAN must cover the host (exact or wildcard *.<parent>). $san = [string]$probe.ServedSan $covers = $false if ($san) { $covers = ($san -match [regex]::Escape($hostName)) if (-not $covers -and $hostName.Contains('.')) { $parent = $hostName.Substring($hostName.IndexOf('.') + 1) $covers = ($san -match ('\*\.' + [regex]::Escape($parent))) } } $data.SanCoversHost = $covers if (-not $covers) { return New-SqlCertResult -Stage Verify -Status Failed ` -Detail "Served certificate SAN does not cover host '$hostName' (SAN: $san). Browsers will reject this as a name mismatch." -Data $data } # Step 8 — healthy. New-SqlCertResult -Stage Verify -Status Success ` -Detail "RS '$RsInstance' serves '$testUrl' (HTTP $($probe.StatusCode)); served cert SAN covers '$hostName'$(if ($ExpectedThumbprint) { ' and matches the expected thumbprint' })." ` -Data $data } catch { New-SqlCertResult -Stage Verify -Status Failed ` -Detail "Test-RsHttpsEndpoint encountered an unexpected error: $($_.Exception.Message)" -Data $null } } |