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
    }
}