Private/Invoke-RsHttpProbe.ps1

# =============================================================================
# Script : Private/Invoke-RsHttpProbe.ps1
# Author : Keith Ramsey
# =============================================================================
# Change Log
# -----------------------------------------------------------------------------
# 2026-06-29 cross-suite meta Claude Phase 5.1 (DR-016): probe an RS HTTPS URL
# end to end -- issue the GET (treating 401 Negotiate
# as a live response) AND capture the certificate
# actually presented on the wire (thumbprint + SAN)
# via SslStream. Isolated as a private helper so
# Test-RsHttpsEndpoint is unit-testable by mocking it.
# Windows PowerShell 5.1 compatible (SAN parsed from
# the 2.5.29.17 extension; no DnsNameList).
# =============================================================================
# Decision Contract:
# - Never throws; returns a result object with a ProbeError on any failure.
# - Read-only network probe; no state change.
# - Retries transient cold-start failures (RS app-domain warm-up).
# =============================================================================

function Invoke-RsHttpProbe {
    <#
    .SYNOPSIS
        Probes an RS HTTPS URL: HTTP status + the served certificate (thumb/SAN).
    .DESCRIPTION
        Issues an HTTP GET to the URL and captures the status code, treating a
        401 (RSWindowsNegotiate challenge) as a live response rather than a
        failure. Separately opens a TLS connection and captures the certificate
        the server actually presents -- thumbprint and Subject Alternative Names
        -- so the caller can confirm the right cert is bound and its SAN covers
        the host. Never throws.
    .PARAMETER Url
        The full HTTPS URL to probe (e.g. 'https://reports.example.com/ReportServer').
    .PARAMETER TimeoutSec
        Per-attempt timeout in seconds. Default 30.
    .PARAMETER Retries
        Attempts for the GET before giving up (RS cold-start can 503 briefly).
        Default 5.
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Keys: StatusCode (int|null), ServedThumbprint (string|null),
        ServedSan (string|null), ProbeError (string|null).
    .NOTES
        Steps:
          1. Parse host/port from the URL.
          2. GET with retry; on WebException read the HTTP status (401 = live);
             retry only when there is no HTTP response (transient/cold start).
          3. Open a TCP+SslStream to host:port; accept any cert (we inspect, not
             trust) and capture the served cert.
          4. Read served thumbprint; parse the SAN from extension OID 2.5.29.17.
          5. Return the structured probe result; never throw.
    #>

    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string] $Url,
        [int] $TimeoutSec = 30,
        [int] $Retries = 5
    )

    $statusCode = $null
    $servedThumb = $null
    $servedSan = $null
    $probeError = $null

    # Step 1 — parse host/port.
    try {
        $u = [uri]$Url
        $targetHost = $u.Host
        $targetPort = if ($u.Port -gt 0) { $u.Port } else { 443 }
    }
    catch {
        return [pscustomobject]@{ StatusCode = $null; ServedThumbprint = $null; ServedSan = $null; ProbeError = "Invalid URL '$Url': $($_.Exception.Message)" }
    }

    # Step 2 — GET with retry (401 = healthy live response).
    for ($try = 1; $try -le [Math]::Max(1, $Retries); $try++) {
        try {
            $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop
            $statusCode = [int]$resp.StatusCode
            break
        }
        catch {
            # StrictMode-safe: not every exception type carries a .Response.
            $respObj = if ($_.Exception -and $_.Exception.PSObject.Properties['Response']) { $_.Exception.Response } else { $null }
            if ($respObj) {
                # An HTTP response (e.g. 401 Negotiate) -- the endpoint is live.
                try { $statusCode = [int]$respObj.StatusCode } catch { $statusCode = $null }
                break
            }
            # No HTTP response = transient/cold start or TLS/connection error.
            $probeError = $_.Exception.Message
            if ($try -lt $Retries) { Start-Sleep -Seconds 8 }
        }
    }

    # Step 3-4 — capture the served certificate (thumbprint + SAN).
    $tcp = $null; $ssl = $null
    try {
        $tcp = New-Object System.Net.Sockets.TcpClient($targetHost, $targetPort)
        # Accept any cert (we INSPECT it, we don't trust it); args intentionally ignored.
        $cb = [System.Net.Security.RemoteCertificateValidationCallback] { $true }
        $ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false, $cb)
        $ssl.AuthenticateAsClient($targetHost)
        $served = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate)
        $servedThumb = $served.Thumbprint
        $sanExt = $served.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
        $servedSan = if ($sanExt) { ($sanExt.Format($false) -replace "`r?`n", ' ').Trim() } else { $null }
    }
    catch {
        if (-not $probeError) { $probeError = "TLS probe failed: $($_.Exception.Message)" }
    }
    finally {
        if ($ssl) { $ssl.Dispose() }
        if ($tcp) { $tcp.Close() }
    }

    [pscustomobject]@{
        StatusCode       = $statusCode
        ServedThumbprint = $servedThumb
        ServedSan        = $servedSan
        ProbeError       = $probeError
    }
}