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