Private/Test-PatServerReachable.ps1

function Test-PatServerReachable {
    <#
    .SYNOPSIS
        Tests if a Plex server is reachable at a given URI.
 
    .DESCRIPTION
        Attempts to connect to a Plex server and verify it responds correctly.
        Uses a short timeout to quickly determine reachability without blocking.
 
    .PARAMETER ServerUri
        The URI of the Plex server to test.
 
    .PARAMETER Token
        Optional Plex authentication token for servers that require authentication.
 
    .PARAMETER TimeoutSeconds
        Connection timeout in seconds. Default is 3 seconds for quick local network testing.
 
    .PARAMETER SkipCertificateCheck
        If specified, skips TLS certificate validation for HTTPS connections.
        Only use this for trusted local servers with self-signed certificates.
        WARNING: Skipping certificate validation exposes you to man-in-the-middle attacks.
 
    .OUTPUTS
        PSCustomObject with properties:
        - Reachable: Boolean indicating if server responded
        - ResponseTimeMs: Response time in milliseconds (if reachable)
        - Error: Error message (if not reachable)
 
    .EXAMPLE
        $result = Test-PatServerReachable -ServerUri "http://192.168.1.100:32400"
        if ($result.Reachable) {
            Write-Host "Server responded in $($result.ResponseTimeMs)ms"
        }
 
    .NOTES
        This function is designed for quick reachability checks, not full validation.
        A successful response indicates the server is accessible, but doesn't verify
        that all functionality works correctly.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ServerUri,

        [Parameter(Mandatory = $false)]
        [string]
        $Token,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 30)]
        [int]
        $TimeoutSeconds = 3,

        [Parameter(Mandatory = $false)]
        [switch]
        $SkipCertificateCheck
    )

    $uri = Join-PatUri -BaseUri $ServerUri -Endpoint '/'

    $requestParams = @{
        Uri         = $uri
        Method      = 'Get'
        TimeoutSec  = $TimeoutSeconds
        ErrorAction = 'Stop'
        Headers     = @{ 'Accept' = 'application/json' }
    }

    # Add token if provided
    if (-not [string]::IsNullOrWhiteSpace($Token)) {
        $requestParams.Headers['X-Plex-Token'] = $Token
    }

    Write-Verbose "Testing reachability of $ServerUri (timeout: ${TimeoutSeconds}s)"

    # Handle HTTPS certificate validation if opt-in skip is requested
    # This must be explicitly requested to prevent man-in-the-middle attacks
    $certValidationCallback = $null
    $certCallbackChanged = $false
    $certMutex = $null
    if ($SkipCertificateCheck -and ($ServerUri -match '^https://')) {
        if ($PSVersionTable.PSVersion.Major -ge 6) {
            # PowerShell 6.0+ supports SkipCertificateCheck parameter
            $requestParams['SkipCertificateCheck'] = $true
        }
        else {
            # PowerShell 5.1 requires ServerCertificateValidationCallback
            # Use a named mutex to prevent race conditions when multiple calls modify the global callback
            $certMutex = [System.Threading.Mutex]::new($false, 'Global\PlexAutomationToolkit_CertCallback')
            $mutexAcquired = $certMutex.WaitOne(10000) # 10 second timeout
            if (-not $mutexAcquired) {
                # Could not acquire mutex - return error rather than risk race condition
                $certMutex.Dispose()
                Write-Verbose "Could not acquire certificate callback mutex for certificate skip"
                return [PSCustomObject]@{
                    Reachable      = $false
                    ResponseTimeMs = $null
                    Error          = "Could not safely skip certificate validation (mutex timeout)"
                }
            }
            $certValidationCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback
            [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
            $certCallbackChanged = $true
        }
    }

    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        $null = Invoke-RestMethod @requestParams
        $stopwatch.Stop()

        Write-Verbose "Server at $ServerUri is reachable (${($stopwatch.ElapsedMilliseconds)}ms)"

        return [PSCustomObject]@{
            Reachable      = $true
            ResponseTimeMs = $stopwatch.ElapsedMilliseconds
            Error          = $null
        }
    }
    catch {
        $stopwatch.Stop()
        $errorMessage = $_.Exception.Message

        # 401/403 means server is reachable but needs auth - still consider it reachable
        if ($errorMessage -match '401|403|Unauthorized|Forbidden') {
            Write-Verbose "Server at $ServerUri is reachable (requires authentication)"

            return [PSCustomObject]@{
                Reachable      = $true
                ResponseTimeMs = $stopwatch.ElapsedMilliseconds
                Error          = $null
            }
        }

        Write-Verbose "Server at $ServerUri is not reachable: $errorMessage"

        return [PSCustomObject]@{
            Reachable      = $false
            ResponseTimeMs = $null
            Error          = $errorMessage
        }
    }
    finally {
        # Restore original certificate validation callback if we changed it
        if ($certCallbackChanged) {
            [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $certValidationCallback
        }
        # Release mutex if acquired
        if ($certMutex) {
            $certMutex.ReleaseMutex()
            $certMutex.Dispose()
        }
    }
}