From a7035a58ed643de12f8aef37eb88a4aa0ef9438c Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:21:49 -0300 Subject: [PATCH 01/10] Update documentation for Azure Arc Connectivity Check Enhance the Azure Arc Connectivity Check script documentation with detailed usage instructions, parameter descriptions, and improvements in functionality. --- .../arc_endpoint_check/_index.md | 82 +++++++++++++++---- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/script_automation/arc_endpoint_check/_index.md b/script_automation/arc_endpoint_check/_index.md index 2da93f90..982a81a6 100644 --- a/script_automation/arc_endpoint_check/_index.md +++ b/script_automation/arc_endpoint_check/_index.md @@ -4,37 +4,85 @@ title: "Azure Arc Connectivity Check" linkTitle: "Azure Arc Connectivity Check" weight: 1 description: > + Validate Azure Connected Machine agent connectivity and endpoints for public + or Private Link deployments, with automatic mode detection and proxy awareness. --- -## Overview +## Overview -This script was created to help identify connectivity issues with the Azure Arc Machine Agent and its endpoints. It tests the necessary URLs, validates Azure Arc functionality, and performs DNS resolution, network connectivity, and HTTP request checks, logging the results for review. +This script helps identify connectivity issues with the Azure Connected Machine agent +and its required endpoints. It validates the endpoint list from the official Azure Arc +network requirements, performs **DNS resolution**, **TCP/443** reachability, and **HTTP** +probes, runs `azcmagent check`, and logs everything for review. + +Compared to earlier versions, it is fully **parameter-driven** (no manual editing of the +script is required) and adds: + +- **Automatic Public vs Private Link detection** (`azcmagent show` + DNS heuristic), with a + manual override (`-Mode`). +- **Proxy awareness** that mirrors the Connected Machine agent precedence + (`azcmagent proxy.url` > `HTTPS_PROXY`) and honors `proxy.bypass` categories + (AAD, ARM, Arc, AMA, ArcData). The Windows system-wide proxy (WinHTTP/WinINET) is + reported but never applied automatically, matching the agent's behavior. +- **Optional extension endpoint groups**: SQL Server enabled by Azure Arc, Azure Monitor + Agent (AMA), Microsoft Defender for Endpoint (MDE), and Windows Admin Center (WAC). +- **IPv4-first DNS resolution** (Private Link uses A records), avoiding false "public" + classification when public AAAA records coexist. +- A machine-readable **exit code** (`0` = all checks OK, `1` = at least one failure). ## Prerequisites -- PowerShell -- Network connectivity +- **Windows PowerShell 5.1 or later** (Windows only — the script uses `netsh`, + `Resolve-DnsName`, and `azcmagent.exe`). +- Outbound network connectivity to the Azure Arc endpoints (directly or via proxy). +- *(Optional)* The **Azure Connected Machine agent** (`azcmagent.exe`). It is only needed + for the final `azcmagent check`; DNS/TCP/HTTP tests run without it. +- Run from an **elevated PowerShell** session for the most complete results. ## Getting Started -Download the [ArcEndpointCheck.ps1](./ArcEndpointCheck.ps1) and follow these steps to set up and use the script: +Download [ArcEndpointCheck.ps1](./ArcEndpointCheck.ps1) and run it on the server where the +Azure Arc agent is (or will be) installed. -1. **Define the Region** - Set the region for your Azure Arc deployment. For example: - `$region = "brazilsouth"` +Unlike previous versions, **you no longer edit the script**. Everything is controlled by +parameters: -2. **Define the Log File Path** - Specify the location where the log file will be saved. For example: - `$logFilePath = "C:\temp\Arclogfile.txt"` +| Parameter | Description | Default | +| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------ | +| `-Region` | Azure region (e.g., `eastus2`, `brazilsouth`). | `eastus2` | +| `-Mode` | `Auto`, `Public`, or `Private`. `Private` automatically adds `--enable-pls-check` to `azcmagent check`. | `Auto` | +| `-ProxyUrl` | Explicit HTTP/HTTPS proxy (e.g., `http://10.0.1.4:8443`). If omitted, auto-detects `azcmagent proxy.url` then `HTTPS_PROXY`. | *(auto-detect)* | +| `-LogFilePath` | Path to the log file. | `C:\temp\Arclogfile.txt` | +| `-IncludeSQL` | Adds Azure Arc-enabled SQL Server endpoints (`*.arcdataservices.com`, plus `graph.microsoft.com` for Microsoft Entra auth). | *(off)* | +| `-IncludeAMA` | Adds Azure Monitor Agent endpoints. | *(off)* | +| `-IncludeMDE` | Adds Microsoft Defender for Endpoint endpoints. | *(off)* | +| `-IncludeWAC` | Adds Windows Admin Center endpoints. | *(off)* | +| `-CheckIncludeAll` | Runs `azcmagent check` with `--extensions all --include-all` (all extensions + extended use cases such as Windows Server pay-as-you-go). | *(off)* | -3. **Choose Public or Private Deployment** - Determine whether your Azure Arc instance will be public or private. - If you're using a public deployment, make sure to remove the `--enable-pls-check` parameter from the script. +> The public/private choice is handled automatically. In `Private` mode the script adds +> `--enable-pls-check` for you — there is no longer any parameter to remove manually. ## Using the Script -Execute the script on the server where the Azure Arc Agent will be installed. When running the script, keep in mind environmental factors such as firewall settings, proxy configuration, region, and whether the connection is public or private. Make the necessary adjustments in the script to account for these aspects. The script includes a check with `AzcmAgent.exe`, so ensure that the Azure Arc Agent is already installed on the server before running it. +Run the script on the target server, keeping in mind environmental factors such as +firewall rules, proxy configuration, region, and whether the connection is public or +private. Examples: + +```powershell +# Auto-detect Public/Private, default region (eastus2) +.\ArcEndpointCheck.ps1 + +# Specific region + SQL and AMA endpoints +.\ArcEndpointCheck.ps1 -Region brazilsouth -IncludeSQL -IncludeAMA + +# Force an explicit proxy for all HTTP tests +.\ArcEndpointCheck.ps1 -Region eastus2 -ProxyUrl http://10.0.1.4:8443 + +# Force Public mode (useful before the agent is installed) +.\ArcEndpointCheck.ps1 -Mode Public -## Contributions +# Force Private Link validation (adds --enable-pls-check) with a custom log path +.\ArcEndpointCheck.ps1 -Mode Private -LogFilePath D:\logs\arc-pls.txt -Contributions are welcome! Feel free to open an _issue_ or submit a _pull request_ to improve this repository. \ No newline at end of file +# Full pre-onboarding validation of all extension endpoints +.\ArcEndpointCheck.ps1 -Mode Private -CheckIncludeAll -Verbose -IncludeSQL -IncludeAMA -IncludeMDE -IncludeWAC From 968b169edbd05e69f2dd9744fe9e9610b9a05e0a Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:47:41 -0300 Subject: [PATCH 02/10] Update print statement from 'Hello' to 'Goodbye' --- .../arc_endpoint_check/ArcEndpointCheck.ps1 | 1003 ++++++++++++++--- 1 file changed, 858 insertions(+), 145 deletions(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index d5895ff4..01cf00b4 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -1,173 +1,886 @@ -# Purpose: This script checks the connectivity and status of Azure endpoints, including both static and dynamic endpoints, -# and validates Azure Arc functionality. It performs DNS resolution, network connectivity, and HTTP request tests, -# logging the results for review. +#Requires -Version 5.1 -# Disclaimer: This script is intended for use in environments where connectivity to Azure endpoints and Azure Arc services -# needs to be validated. It relies on internet access to fetch dynamic endpoints and assumes the presence of `azcmagent.exe` -# for Azure Arc checks. Results are logged to a specified file, and proper access control on logs is advised. +<# +.SYNOPSIS + Validates Azure Arc connectivity, DNS resolution, and endpoint reachability + (public or Azure Private Link). -# Define the region -$region = "brazilsouth" +.DESCRIPTION + - Automatically detects whether the host uses Azure Arc public endpoints or an + Azure Arc Private Link Scope (PLS): + 1) 'azcmagent show -j' - if it reports a privateLinkScope => Private + 2) otherwise, resolves 'gbl.his.arc.azure.com' and classifies as Private + when the IP is RFC1918 (covers the "DNS-based" Private Link scenario) + - Tests DNS, TCP/443, and (for selected endpoints) HTTP. + - Runs 'azcmagent check' with the correct flag for the detected mode. + - Detects and displays the proxy configuration following the agent precedence + (azcmagent proxy.url > HTTPS_PROXY). The Windows system-wide proxy + (WinHTTP/WinINET) is shown for information only, because the agent ignores it. + - Supports environments with Azure Firewall Explicit Proxy. -# Define the log file path -$logFilePath = "C:\temp\Arclogfile.txt" +.PARAMETER Region + Azure region (default: eastus2). -# Ensure log file exists before clearing -if (-Not (Test-Path $logFilePath)) { - New-Item -ItemType File -Path $logFilePath -Force | Out-Null -} else { - Clear-Content -Path $logFilePath -ErrorAction SilentlyContinue -} +.PARAMETER Mode + Auto | Public | Private. Default: Auto. + +.PARAMETER ProxyUrl + HTTP/HTTPS proxy URL (e.g., http://10.0.1.4:8443). If omitted, the script + auto-detects using the same precedence as the Azure Arc agent: 1) azcmagent + proxy.url (agent config - takes precedence); 2) the HTTPS_PROXY environment + variable. The Windows system-wide proxy (WinHTTP/WinINET) is only reported, + never applied automatically - mirroring the agent. + Ref: https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings + +.PARAMETER LogFilePath + Log file path. Default: C:\temp\Arclogfile.txt. + +.PARAMETER IncludeSQL + Includes Azure Arc-enabled SQL Server endpoints: data processing service and + telemetry (*.arcdataservices.com), san-af (legacy), and graph.microsoft.com + (Microsoft Entra authentication). Aligned with the official Arc SQL connectivity + test (DPS => 200, telemetry => 401). + +.PARAMETER IncludeAMA + Includes Azure Monitor Agent (AMA) endpoints. + +.PARAMETER IncludeMDE + Includes Microsoft Defender for Endpoint endpoints. + +.PARAMETER IncludeWAC + Includes Windows Admin Center endpoints. + +.PARAMETER CheckIncludeAll + Makes 'azcmagent check' validate everything: adds '--extensions all' (endpoints + for all supported extensions) and '--include-all' (extended use cases, e.g., + Windows Server pay-as-you-go). Useful before onboarding. Replaces the + '--extensions sql' that -IncludeSQL would add. + +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 + Auto-detects the mode (Public/Private) and uses the default region 'eastus2'. + +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Region brazilsouth -IncludeSQL -IncludeAMA + Runs against brazilsouth including SQL and AMA endpoints. + +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Region eastus2 -ProxyUrl http://10.0.1.4:8443 + Forces an explicit proxy for all HTTP tests. -# Write start of the log -"Script started at $(Get-Date)" | Out-File -FilePath $logFilePath -Append +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Region westeurope -Mode Public + Forces Public mode on westeurope (useful to validate the internet endpoint + list when the host does not have the agent installed yet). -# Define the list of static endpoints -$staticEndpoints = @( - "login.windows.net", "login.microsoftonline.com", "pas.windows.net", # AAD - "management.azure.com", # ARM - "global.handler.control.monitor.azure.com", # AMA - "gbl.his.arc.azure.com", "agentserviceapi.guestconfiguration.azure.com", # Arc - "dataprocessingservice.$region.arcdataservices.com", "telemetry.$region.arcdataservices.com" # ArcData and Telemetry +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Region brazilsouth -Mode Private -LogFilePath D:\logs\arc-pls.txt + Forces Private Link validation and writes the log to a custom path. Adds the + '--enable-pls-check' flag to 'azcmagent check'. + +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Region southcentralus -Verbose -IncludeSQL -IncludeAMA -IncludeMDE -IncludeWAC + Runs with detailed verbose output and all endpoint groups. + Common regions: eastus, eastus2, westus2, westus3, centralus, northeurope, + westeurope, uksouth, francecentral, switzerlandnorth, southeastasia, + japaneast, australiaeast, brazilsouth, southafricanorth, uaenorth. + +.EXAMPLE + PS> .\ArcEndpointCheck.ps1 -Mode Private -CheckIncludeAll + Runs 'azcmagent check' with '--extensions all --include-all' (endpoints for all + extensions + extended use cases) in Private mode. + +.NOTES + Requires PowerShell 5.1+ on Windows (uses netsh, Resolve-DnsName, and azcmagent.exe). + azcmagent.exe is optional (only for the final check). + Exit code: 0 = all tests OK; 1 = at least one failure. + Endpoint lists aligned with the Connected Machine agent network requirements and + its extensions (AMA/SQL/MDE/WAC). + +.LINK + https://azurearcjumpstart.com +#> + +[CmdletBinding()] +param( + [string]$Region = 'eastus2', + + [ValidateSet('Auto', 'Public', 'Private')] + [string]$Mode = 'Auto', + + [string]$ProxyUrl, + + [string]$LogFilePath = 'C:\temp\Arclogfile.txt', + + [switch]$IncludeSQL, + [switch]$IncludeAMA, + [switch]$IncludeMDE, + [switch]$IncludeWAC, + + [switch]$CheckIncludeAll ) -# Fetch dynamic endpoints for the specified region -try { - $logMessage = "Running: Invoke-WebRequest -Uri 'https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$region'" - $logMessage | Out-File -FilePath $logFilePath -Append +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # acelera Invoke-WebRequest e Test-NetConnection + +$logDir = Split-Path -Path $LogFilePath -Parent +if ($logDir -and -not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null +} +Set-Content -Path $LogFilePath -Value "Script started at $(Get-Date -Format o)" -Force + +$script:Stats = [ordered]@{ OK = 0; Fail = 0; Warn = 0 } +$script:LogBuffer = [System.Collections.ArrayList]::new() +$script:Results = [System.Collections.ArrayList]::new() + +function Add-Result { + param( + [Parameter(Mandatory)] [string]$Endpoint, + [string]$Group = 'Core', + [string]$IP = '-', + [string]$Type = '-', + [string]$DNS = '-', + [string]$TCP = '-', + [string]$HTTP = '-', + [string]$Latency = '-' + ) + # Check if endpoint already exists and update + $existing = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } + if ($existing) { + if ($IP -ne '-') { $existing.IP = $IP } + if ($Type -ne '-') { $existing.Type = $Type } + if ($DNS -ne '-') { $existing.DNS = $DNS } + if ($TCP -ne '-') { $existing.TCP = $TCP } + if ($HTTP -ne '-') { $existing.HTTP = $HTTP } + if ($Latency -ne '-') { $existing.Latency = $Latency } + } + else { + [void]$script:Results.Add([ordered]@{ + Endpoint = $Endpoint + Group = $Group + IP = $IP + Type = $Type + DNS = $DNS + TCP = $TCP + HTTP = $HTTP + Latency = $Latency + }) + } +} + +function Write-Log { + param( + [Parameter(Mandatory)] [string]$Message, + [ValidateSet('Info', 'OK', 'Fail', 'Warn')] [string]$Level = 'Info', + [switch]$NoCount + ) + $color = @{ Info = 'Gray'; OK = 'Green'; Fail = 'Red'; Warn = 'Yellow' }[$Level] + $line = "[{0}] [{1,-4}] {2}" -f (Get-Date -Format HH:mm:ss), $Level.ToUpper(), $Message + Write-Host $line -ForegroundColor $color + [void]$script:LogBuffer.Add($line) + + if (-not $NoCount) { + if ($Level -eq 'OK') { $script:Stats.OK++ } + if ($Level -eq 'Fail') { $script:Stats.Fail++ } + if ($Level -eq 'Warn') { $script:Stats.Warn++ } + } +} + +function Save-LogBuffer { + if ($script:LogBuffer.Count -gt 0) { + Add-Content -Path $LogFilePath -Value $script:LogBuffer + $script:LogBuffer.Clear() + } +} + +function Test-TcpPort { + param( + [Parameter(Mandatory)] [string]$ComputerName, + [int]$Port = 443, + [int]$TimeoutMs = 5000 + ) + $client = [System.Net.Sockets.TcpClient]::new() + try { + $iar = $client.BeginConnect($ComputerName, $Port, $null, $null) + if ($iar.AsyncWaitHandle.WaitOne($TimeoutMs, $false) -and $client.Connected) { + $client.EndConnect($iar) | Out-Null + return $true + } + return $false + } + catch { return $false } + finally { $client.Close() } +} + +function Invoke-WebRequestSafe { + param( + [Parameter(Mandatory)] [string]$Uri, + [int]$TimeoutSec = 10 + ) + $params = @{ + Uri = $Uri + Method = 'Get' + UseBasicParsing = $true + TimeoutSec = $TimeoutSec + ErrorAction = 'Stop' + } + if ($script:EffectiveProxy) { + $params['Proxy'] = $script:EffectiveProxy + $params['ProxyUseDefaultCredentials'] = $true + } + elseif ($PSVersionTable.PSVersion.Major -ge 6) { + # PS 6+: espelha o agente, que IGNORA o proxy system-wide. Em PS 5.1 o + # mesmo efeito e obtido neutralizando o DefaultWebProxy do .NET no setup. + $params['NoProxy'] = $true + } + return Invoke-WebRequest @params +} + +# --------------------------------------------------------------------------- +# Detecao e exibicao de proxy +# --------------------------------------------------------------------------- +$script:EffectiveProxy = $null + +function Get-ProxyDiagnostics { + Write-Log '=== DIAGNOSTICO DE PROXY ===' Info -NoCount + [void]$script:LogBuffer.Add('') + + # Precedencia do proxy efetivo (usado nos testes HTTP deste script) — espelha + # o comportamento do Azure Connected Machine agent no Windows: + # 1) -ProxyUrl (override explicito do operador) + # 2) azcmagent proxy.url (config do agente — TEM PRECEDENCIA sobre env vars) + # 3) HTTPS_PROXY (env) (system-wide) + # O agente IGNORA o proxy system-wide do Windows (WinHTTP/WinINET); por isso o + # WinHTTP abaixo e apenas REPORTADO, nunca aplicado automaticamente. + # Ref: https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings + + # 1) Parametro -ProxyUrl + if ($ProxyUrl) { + Write-Log "Proxy via parametro: $ProxyUrl" Info -NoCount + $script:EffectiveProxy = $ProxyUrl + } + + # WinHTTP (apenas informativo — o agente ignora o proxy system-wide) + # Nota: o parse do 'netsh' abaixo depende de Windows em INGLES. Em SO + # localizado (ex.: pt-BR) o regex pode nao casar e reportar 'Direct' + # mesmo havendo proxy configurado no WinHTTP. + try { + $winhttp = netsh winhttp show proxy 2>$null + $winhttpText = ($winhttp | Out-String).Trim() + if ($winhttpText -match 'Proxy Server\(s\)\s*:\s*(.+)') { + $winhttpProxy = $Matches[1].Trim() + Write-Log "WinHTTP Proxy: $winhttpProxy (informativo — o agente ignora o proxy system-wide)" Info -NoCount + } + else { + Write-Log 'WinHTTP Proxy: Direct (sem proxy)' Info -NoCount + } + if ($winhttpText -match 'Bypass List\s*:\s*(.+)') { + Write-Log "WinHTTP Bypass: $($Matches[1].Trim())" Info -NoCount + } + } + catch { + Write-Log "WinHTTP: nao foi possivel consultar ($($_.Exception.Message))" Warn + } + + # 2) azcmagent config (proxy.url TEM PRECEDENCIA sobre HTTPS_PROXY) + $azcm = Get-AzcmagentPath + if ($azcm) { + try { + $proxyUrl = & $azcm config get proxy.url 2>$null + if ($proxyUrl -and $proxyUrl.Trim()) { + Write-Log "azcmagent proxy.url: $($proxyUrl.Trim())" Info -NoCount + if (-not $script:EffectiveProxy) { + $script:EffectiveProxy = $proxyUrl.Trim() + } + } + else { + Write-Log 'azcmagent proxy.url: (nao configurado)' Info -NoCount + } + $bypass = & $azcm config get proxy.bypass 2>$null + if ($bypass -and $bypass.Trim()) { + Write-Log "azcmagent proxy.bypass: $($bypass.Trim())" Info -NoCount + } + } + catch { + Write-Log "azcmagent config: falha ao consultar ($($_.Exception.Message))" Warn + } + } + + # 3) Environment variables (verifica Machine -> Process -> User) + $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') + if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Process') } + if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'User') } + $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Machine') + if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process') } + if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'User') } + if ($envProxy) { + Write-Log "Env HTTPS_PROXY: $envProxy" Info -NoCount + if (-not $script:EffectiveProxy) { + $script:EffectiveProxy = $envProxy + } + } + else { + Write-Log 'Env HTTPS_PROXY: (nao definido)' Info -NoCount + } + if ($envNoProxy) { + Write-Log "Env NO_PROXY: $envNoProxy" Info -NoCount + } + + if ($script:EffectiveProxy) { + Write-Log "Proxy efetivo para testes HTTP: $($script:EffectiveProxy)" Info -NoCount + } + else { + Write-Log 'Proxy efetivo: Direct (sem proxy — testes HTTP vao direto, como o agente)' Info -NoCount + } + + [void]$script:LogBuffer.Add('') +} + +# --------------------------------------------------------------------------- +# Deteccao automatica Public vs Private +# --------------------------------------------------------------------------- +function Get-AzcmagentPath { + $candidate = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' + if (Test-Path $candidate) { return $candidate } + return $null +} + +function Test-IsPrivateIp { + param([string]$Ip) + if (-not $Ip) { return $false } + try { + $bytes = ([System.Net.IPAddress]::Parse($Ip)).GetAddressBytes() + } + catch { return $false } + + # RFC1918 + 100.64/10 (CGNAT, comum em redes corporativas) + return ($bytes[0] -eq 10) -or + ($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or + ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or + ($bytes[0] -eq 100 -and $bytes[1] -ge 64 -and $bytes[1] -le 127) +} + +function Resolve-ArcMode { + Write-Log 'Detectando modo Arc (Public/Private)...' Info -NoCount + + # 1) Via azcmagent show -j + $azcm = Get-AzcmagentPath + if ($azcm) { + try { + $json = & $azcm show -j 2>$null | ConvertFrom-Json + $pls = $json.privateLinkScope + if ($pls) { + Write-Log "azcmagent reporta privateLinkScope: $pls" Info -NoCount + return 'Private' + } + else { + # NAO conclui Public aqui: cai para o heuristico DNS abaixo. O + # Private Link pode ser "via DNS" (Private DNS Zones) sem o agente + # expor o PLS localmente em 'azcmagent show -j'. + Write-Log 'azcmagent nao reporta privateLinkScope; confirmando via DNS...' Info -NoCount + } + } + catch { + Write-Log "Falha ao consultar azcmagent show -j: $($_.Exception.Message). Caindo para fallback DNS." Warn + } + } + else { + Write-Log 'azcmagent.exe nao encontrado. Usando fallback DNS.' Warn + } + + # 2) Fallback: resolver gbl.his.arc.azure.com + try { + $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop + $ip = ($dns | Where-Object IPAddress | Select-Object -First 1).IPAddress + if (Test-IsPrivateIp -Ip $ip) { + Write-Log "gbl.his.arc.azure.com resolve para IP privado ($ip) -> Private Link" Info -NoCount + return 'Private' + } + else { + Write-Log "gbl.his.arc.azure.com resolve para IP publico ($ip) -> Public" Info -NoCount + return 'Public' + } + } + catch { + Write-Log 'Nao foi possivel resolver gbl.his.arc.azure.com - assumindo Public.' Warn + return 'Public' + } +} + +# --------------------------------------------------------------------------- +# Detecao de modo e proxy +# --------------------------------------------------------------------------- +Get-ProxyDiagnostics + +# Alinhamento com o agente: o Azure Connected Machine agent IGNORA o proxy +# system-wide do Windows (WinINET/WinHTTP). Se nenhum proxy efetivo foi +# detectado, neutralizamos o DefaultWebProxy do .NET (PS 5.1) para que os +# testes HTTP tambem vao direto. Em PS 6+ isso e feito via -NoProxy. +if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { + try { [System.Net.WebRequest]::DefaultWebProxy = $null } catch { } +} - $response = Invoke-WebRequest -Uri "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$region" -ErrorAction Stop - $dynamicEndpoints = ($response.Content -replace '\[|\]|"|\\n','').Split(',') - "Dynamic endpoints fetched: $($dynamicEndpoints -join ', ')" | Out-File -FilePath $logFilePath -Append -} catch { - "Error fetching dynamic endpoints: $_" | Out-File -FilePath $logFilePath -Append - $dynamicEndpoints = @() +if ($Mode -eq 'Auto') { + $Mode = Resolve-ArcMode } +Write-Log "Modo selecionado: $Mode | Regiao: $Region" Info -NoCount + +# Reset stats: fase de testes comeca aqui (detecao nao conta) +$script:Stats.OK = 0 +$script:Stats.Fail = 0 +$script:Stats.Warn = 0 + +# --------------------------------------------------------------------------- +# Endpoints — organizados por grupo funcional +# --------------------------------------------------------------------------- + +# Endpoints que PODEM resolver para IP privado via Azure Private Link Scope. +# Tudo que NAO esta nesta lista e sempre publico — nao gerar WARN em modo Private. +$canBePrivateEndpoints = @( + 'gbl.his.arc.azure.com' + 'agentserviceapi.guestconfiguration.azure.com' + 'dc.services.visualstudio.com' + 'global.handler.control.monitor.azure.com' +) + +# Core Arc (obrigatorios) — alinhado a network-requirements do Connected Machine agent. +# Doc: https://learn.microsoft.com/azure/azure-arc/servers/network-requirements +$coreEndpoints = @( + # AAD / Identity (sempre; Public) + 'login.windows.net' + 'login.microsoftonline.com' + 'pas.windows.net' -# Combine static and dynamic endpoints -$allEndpoints = $staticEndpoints + $dynamicEndpoints + # ARM (conexao/desconexao; Public salvo Resource Management Private Link) + 'management.azure.com' -# List of allowed endpoints for HTTP request -$allowedEndpoints = @( - "login.windows.net", - "login.microsoftonline.com", - "dataprocessingservice.$region.arcdataservices.com", - "telemetry.$region.arcdataservices.com" + # Arc HIMDS (sempre; Private via PLS) + 'gbl.his.arc.azure.com' + + # Guest Configuration / gestao de extensoes (sempre; Private via PLS) + 'agentserviceapi.guestconfiguration.azure.com' + + # Instalacao/atualizacao do agente (Public) + 'packages.microsoft.com' + 'download.microsoft.com' + + # Telemetria (opcional; NAO usado em agentes 1.24+; Public) + 'dc.services.visualstudio.com' ) -# Filter the dynamic endpoints to match the allowed list -$filteredDynamicEndpoints = $allowedEndpoints +# SQL endpoints (opcional via -IncludeSQL) — Arc-enabled SQL Server. +# Doc: network-requirements + sql/.../data-collection. Todos Public; TLS 1.2/1.3. +$sqlEndpoints = @() +if ($IncludeSQL) { + $sqlEndpoints = @( + # Data processing service + telemetria (extensoes a partir de mar/2024) + "dataprocessingservice.$Region.arcdataservices.com" + "telemetry.$Region.arcdataservices.com" + # Legado: usado por extensoes ate 13/fev/2024 + "san-af-$Region-prod.azurewebsites.net" + # Autenticacao Microsoft Entra do Arc SQL (Public). So necessario se usar + # Entra auth; NAO e endpoint core do agente. Reachable direto, mas pode ser + # bloqueado em proxy split-tunnel -> apenas DNS/TCP (sem HTTP probe). + 'graph.microsoft.com' + ) +} -# Combine static endpoints with the filtered dynamic endpoints for HTTP requests -$finalEndpointsForRequest = $filteredDynamicEndpoints +# AMA endpoints (opcional via -IncludeAMA) — Azure Monitor Agent. +# Doc: azure-monitor-agent-network-configuration. Endpoints .ods e +# .ingest.monitor exigem IDs especificos -> nao testaveis genericamente. +$amaEndpoints = @() +if ($IncludeAMA) { + $amaEndpoints = @( + 'global.handler.control.monitor.azure.com' # control service + 'global.prod.microsoftmetrics.com' # metrics service + "$Region.handler.control.monitor.azure.com" # DCRs da regiao + "$Region.monitoring.azure.com" # custom metrics (opcional) + ) +} -# Iterate over all endpoints to test connectivity, DNS resolution, and HTTP response for the filtered dynamic endpoints -foreach ($endpoint in $allEndpoints) { - $trimmedEndpoint = $endpoint.Trim() +# MDE endpoints (opcional via -IncludeMDE) +$mdeEndpoints = @() +if ($IncludeMDE) { + $mdeEndpoints = @( + 'unitedstates.x.cp.wd.microsoft.com' + 'us-v20.events.data.microsoft.com' + ) +} + +# WAC endpoints (opcional via -IncludeWAC) +$wacEndpoints = @() +if ($IncludeWAC) { + $wacEndpoints = @( + "$Region.service.waconazure.com" + 'pas.windows.net' + ) +} + +# Endpoints que respondem HTTP (validacao L7 — 200/400/401/403/404 = reachable). +# NAO inclui graph.microsoft.com: e endpoint de Entra auth do Arc SQL (opcional) e +# costuma ser bloqueado em proxy split-tunnel; o proprio teste oficial de +# conectividade do Arc SQL valida apenas DPS + telemetria. +$httpProbeEndpoints = @( + 'login.windows.net' + 'login.microsoftonline.com' + 'management.azure.com' +) +if ($IncludeSQL) { + # Alinhado ao teste oficial do Arc SQL: DPS espera 200; telemetria espera 401 + # (ambos tratados como reachable aqui). + $httpProbeEndpoints += "dataprocessingservice.$Region.arcdataservices.com" + $httpProbeEndpoints += "telemetry.$Region.arcdataservices.com" +} - # DNS resolution test - $dnsLogMessage = "Testing DNS resolution for: $($trimmedEndpoint)" - $dnsLogMessage | Out-File -FilePath $logFilePath -Append +# Mapeia grupo por endpoint para o sumario. Core tem PRECEDENCIA: se um endpoint +# aparece em mais de um grupo (ex.: pas.windows.net em Core e WAC), mantemos 'Core'. +$endpointGroupMap = @{} +foreach ($ep in $coreEndpoints) { $endpointGroupMap[$ep] = 'Core' } +foreach ($ep in $sqlEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'SQL' } } +foreach ($ep in $amaEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'AMA' } } +foreach ($ep in $mdeEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'MDE' } } +foreach ($ep in $wacEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'WAC' } } +# Dynamic allowlist (somente em modo publico; em PLS o trafego e via PE) +$dynamicEndpoints = @() +if ($Mode -eq 'Public') { try { - $logMessage = "Running: Resolve-DnsName -Name $($trimmedEndpoint)" - $logMessage | Out-File -FilePath $logFilePath -Append - - $dnsResult = Resolve-DnsName -Name $trimmedEndpoint -ErrorAction Stop - $dnsOutput = "Success: DNS resolution succeeded for $($trimmedEndpoint): $($dnsResult.Name)" - Write-Host $dnsOutput -ForegroundColor Green - $dnsOutput | Out-File -FilePath $logFilePath -Append - } catch { - $dnsError = "Error: DNS resolution failed for $($trimmedEndpoint) - $_" - Write-Host $dnsError -ForegroundColor Red - $dnsError | Out-File -FilePath $logFilePath -Append - } - - # Connectivity test - $connectivityLogMessage = "Testing connectivity for: $($trimmedEndpoint)" - $connectivityLogMessage | Out-File -FilePath $logFilePath -Append + Write-Log 'Buscando endpoints dinamicos do guestnotificationservice...' Info -NoCount + $uri = "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$Region" + $resp = Invoke-WebRequestSafe -Uri $uri + $dynamicEndpoints = @($resp.Content | ConvertFrom-Json) | Where-Object { $_ } + if ($dynamicEndpoints.Count -gt 0) { + $totalGNS = $dynamicEndpoints.Count + + # Filtrar: manter apenas endpoints primarios da regiao. + # Namespaces primarios contem 'p-' (ex: 1p-, 2p-), secundarios contem 's-'. + # Extrair cluster IDs dos primarios e filtrar children por eles. + $primaryClusterIds = [System.Collections.ArrayList]::new() + foreach ($dep in $dynamicEndpoints) { + if ($dep -match '^azgn-.+\dp-.+?-(\w+)\.servicebus') { + [void]$primaryClusterIds.Add($Matches[1]) + } + } + + if ($primaryClusterIds.Count -gt 0) { + $filteredGNS = [System.Collections.ArrayList]::new() + foreach ($dep in $dynamicEndpoints) { + if ($dep -match '^azgn-') { + [void]$filteredGNS.Add($dep) # sempre manter namespace-level + } + else { + foreach ($cid in $primaryClusterIds) { + if ($dep -like "*$cid*") { + [void]$filteredGNS.Add($dep) + break + } + } + } + } + $skipped = $totalGNS - $filteredGNS.Count + $dynamicEndpoints = @($filteredGNS) + if ($skipped -gt 0) { + Write-Log "Endpoints dinamicos obtidos: $totalGNS total, $($filteredGNS.Count) primarios ($skipped secundarios filtrados)" OK + } + else { + Write-Log "Endpoints dinamicos obtidos: $totalGNS endpoint(s)" OK + } + } + else { + Write-Log "Endpoints dinamicos obtidos: $totalGNS endpoint(s)" OK + } + + foreach ($dep in $dynamicEndpoints) { + $endpointGroupMap[$dep] = 'GNS' + } + } + } + catch { + # Allowlist dinamica e AUXILIAR: sua indisponibilidade nao deve derrubar o + # exit code (WARN, nao FAIL). Comum ao forcar -Mode Public num host que, na + # pratica, roteia o GNS via Private Link / firewall. + Write-Log "Falha ao obter endpoints dinamicos (allowlist auxiliar): $($_.Exception.Message)" Warn + } +} +else { + Write-Log 'Modo Private: pulando consulta de allowlist publico.' Info -NoCount +} + +$allEndpoints = @( + $coreEndpoints + $sqlEndpoints + $amaEndpoints + $mdeEndpoints + + $wacEndpoints + $dynamicEndpoints | + Where-Object { $_ } | + Select-Object -Unique +) + +Write-Log "Total de endpoints a testar: $($allEndpoints.Count)" Info -NoCount +[void]$script:LogBuffer.Add('') + +# --------------------------------------------------------------------------- +# Testes: DNS + TCP/443 (validacao de coerencia com o modo detectado) +# --------------------------------------------------------------------------- +foreach ($ep in $allEndpoints) { + $ep = $ep.Trim() + if (-not $ep) { continue } + + [void]$script:LogBuffer.Add('-' * 60) + $group = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Dyn' } + Add-Result -Endpoint $ep -Group $group + + # DNS (com 1 retry em falha transitoria, ex.: SERVFAIL ao resolver muitos nomes) + $dns = $null + $dnsErr = $null + foreach ($attempt in 1..2) { + try { $dns = Resolve-DnsName -Name $ep -ErrorAction Stop; $dnsErr = $null; break } + catch { $dnsErr = $_; if ($attempt -lt 2) { Start-Sleep -Milliseconds 300 } } + } + if ($dnsErr) { + # Endpoints dinamicos (GNS) sao AUXILIARES: uma falha de DNS neles vira WARN + # (nao FAIL), pois um SERVFAIL transitorio ao resolver dezenas de nomes + # 'servicebus' nao deve derrubar o exit code. Demais grupos permanecem FAIL. + $existingD = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($group -eq 'GNS') { + Write-Log "DNS WARN $ep - $($dnsErr.Exception.Message) (endpoint dinamico/auxiliar)" Warn + if ($existingD) { $existingD.DNS = 'WARN' } + } + else { + Write-Log "DNS FAIL $ep - $($dnsErr.Exception.Message)" Fail + if ($existingD) { $existingD.DNS = 'FAIL' } + } + continue + } + + # Preferir IPv4 (registro A): o Azure Private Link e a maioria dos endpoints + # Arc sao resolvidos por A-record. Um AAAA (IPv6) publico pode coexistir com + # o A privado; se escolhido, causa classificacao PUBLIC incorreta e testes + # por um caminho IPv6 possivelmente inexistente/nao roteado. + $rec = $dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1 + if (-not $rec) { $rec = $dns | Where-Object IPAddress | Select-Object -First 1 } + $ip = $rec.IPAddress + $kind = if (Test-IsPrivateIp -Ip $ip) { 'PRIVATE' } else { 'PUBLIC' } + + # Update result + $existing = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing) { $existing.IP = $ip; $existing.Type = $kind } + + # Alerta de mismatch DNS x modo + # Apenas endpoints em $canBePrivateEndpoints devem resolver para IP privado. + # Todos os outros (AAD, ARM, CDN, SQL, AMA, MDE, WAC, GNS) sao sempre publicos. + $canBePrivate = $canBePrivateEndpoints -contains $ep + $mismatch = $false + if ($Mode -eq 'Private' -and $kind -eq 'PUBLIC' -and $canBePrivate) { + $mismatch = $true + } + elseif ($Mode -eq 'Public' -and $kind -eq 'PRIVATE') { + $mismatch = $true + } + if ($mismatch) { + Write-Log "DNS WARN $ep -> $ip [$kind] (esperado para modo $Mode era o oposto)" Warn + $existing2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing2) { $existing2.DNS = 'WARN' } + } + else { + Write-Log "DNS OK $ep -> $ip [$kind]" OK + $existing2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing2) { $existing2.DNS = 'OK' } + } + # TCP/443 (TcpClient com timeout — muito mais rapido que Test-NetConnection) + $tcpSw = [System.Diagnostics.Stopwatch]::StartNew() + $tcpOk = Test-TcpPort -ComputerName $ep -Port 443 -TimeoutMs 5000 + $tcpSw.Stop() + $latencyMs = [math]::Round($tcpSw.Elapsed.TotalMilliseconds, 0) + + $existing3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($tcpOk) { + Write-Log "TCP OK ${ep}:443 (${latencyMs}ms)" OK + if ($existing3) { $existing3.TCP = 'OK'; $existing3.Latency = "${latencyMs}ms" } + } + else { + Write-Log "TCP FAIL ${ep}:443 (timeout/recusado)" Fail + if ($existing3) { $existing3.TCP = 'FAIL'; $existing3.Latency = 'timeout' } + } +} + +# --------------------------------------------------------------------------- +# Testes HTTP (401/403/400 sao considerados sucesso: endpoint exige auth) +# Detecta azcmagent proxy.bypass para pular HTTP tests em endpoints bypassados +# --------------------------------------------------------------------------- +$proxyBypassCategories = @() +$azcmPath = Get-AzcmagentPath +if ($azcmPath -and $script:EffectiveProxy) { try { - $logMessage = "Running: Test-Connection -ComputerName $($trimmedEndpoint) -Count 1" - $logMessage | Out-File -FilePath $logFilePath -Append - - $pingResult = Test-Connection -ComputerName $trimmedEndpoint -Count 1 -ErrorAction Stop - $pingOutput = "Success: Connectivity succeeded for $($trimmedEndpoint): Response time: $($pingResult.ResponseTime)ms" - Write-Host $pingOutput -ForegroundColor Green - $pingOutput | Out-File -FilePath $logFilePath -Append - } catch { - $pingError = "Error: Connectivity test failed for $($trimmedEndpoint) - $_" - Write-Host $pingError -ForegroundColor Red - $pingError | Out-File -FilePath $logFilePath -Append + $bypassRaw = & $azcmPath config get proxy.bypass 2>$null + if ($bypassRaw -and $bypassRaw.Trim()) { + $bypassClean = $bypassRaw.Trim().Trim('[', ']') + $proxyBypassCategories = $bypassClean -split ',' | ForEach-Object { $_.Trim() } + } } + catch { } +} + +# Mapa de categorias de bypass -> endpoints afetados (conforme doc oficial: +# https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings). +# IMPORTANTE: 'graph.microsoft.com' NAO e coberto por nenhum bypass — o agente +# usa o proxy para ele; por isso ele NAO deve ser pulado nos testes HTTP. +# 'ArcData' e valido a partir do agente 1.36; em versoes anteriores os endpoints +# arcdataservices ficavam sob a categoria 'Arc'. +$bypassCategoryEndpoints = @{ + 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') + 'ARM' = @('management.azure.com') + 'AMA' = @( + 'global.handler.control.monitor.azure.com' + "$Region.handler.control.monitor.azure.com" + 'management.azure.com' + "$Region.monitoring.azure.com" + ) + 'Arc' = @('gbl.his.arc.azure.com', 'agentserviceapi.guestconfiguration.azure.com') + 'ArcData' = @( + "dataprocessingservice.$Region.arcdataservices.com" + "telemetry.$Region.arcdataservices.com" + ) +} - "----------------------------------------" | Out-File -FilePath $logFilePath -Append +$httpBypassedEndpoints = [System.Collections.ArrayList]::new() +foreach ($cat in $proxyBypassCategories) { + if ($bypassCategoryEndpoints.ContainsKey($cat)) { + foreach ($bep in $bypassCategoryEndpoints[$cat]) { + [void]$httpBypassedEndpoints.Add($bep) + } + } } -# HTTP request test (only for filtered dynamic endpoints) -foreach ($endpoint in $finalEndpointsForRequest) { - $trimmedEndpoint = $endpoint.Trim() +foreach ($ep in $httpProbeEndpoints) { + $ep = $ep.Trim() + if (-not $ep) { continue } + + # Se o endpoint esta no bypass do azcmagent e usamos proxy, HTTP test via proxy daria falso positivo + if ($httpBypassedEndpoints -contains $ep) { + Add-Result -Endpoint $ep -HTTP 'SKIP (bypass)' + Write-Log "HTTP SKIP $ep (azcmagent proxy.bypass cobre este endpoint — agente nao usa proxy)" Info -NoCount + continue + } + + [void]$script:LogBuffer.Add('-' * 60) + Add-Result -Endpoint $ep + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + $resp = Invoke-WebRequestSafe -Uri "https://$ep" -TimeoutSec 10 + $sw.Stop() + $elapsed = [math]::Round($sw.Elapsed.TotalSeconds, 2) + Write-Log "HTTP OK $ep -> $($resp.StatusCode) em ${elapsed}s" OK + $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing4) { $existing4.HTTP = "OK ($($resp.StatusCode))" } + } + catch { + $sw.Stop() + $code = $null + if ($_.Exception.Response) { + try { $code = [int]$_.Exception.Response.StatusCode } catch { } + } + if ($code -in 400, 401, 403, 404) { + Write-Log "HTTP OK $ep -> $code (esperado sem auth/sem root handler)" OK + $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing4) { $existing4.HTTP = "OK ($code)" } + } + elseif ($code) { + Write-Log "HTTP FAIL $ep -> $code" Fail + $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing4) { $existing4.HTTP = "FAIL ($code)" } + } + else { + Write-Log "HTTP FAIL $ep - $($_.Exception.Message)" Fail + $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($existing4) { $existing4.HTTP = 'FAIL' } + } + } +} - # HTTP request test - $httpLogMessage = "Testing HTTP request for: $($trimmedEndpoint)" - $httpLogMessage | Out-File -FilePath $logFilePath -Append +# --------------------------------------------------------------------------- +# azcmagent check +# --------------------------------------------------------------------------- +[void]$script:LogBuffer.Add('=' * 60) +$azcm = Get-AzcmagentPath +if ($azcm) { + $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud') + if ($CheckIncludeAll) { + # Doc oficial: '--extensions' e '--include-all' sao ORTOGONAIS. + # --extensions all -> endpoints de TODAS as extensoes (SQL, etc.) + # --include-all -> casos de uso ESTENDIDOS (ex.: Windows Server PAYG) + # Combinamos os dois para cobertura total. + $checkArgs += @('--extensions', 'all', '--include-all') + } + elseif ($IncludeSQL) { + $checkArgs += @('--extensions', 'sql') + } + if ($Mode -eq 'Private') { $checkArgs += '--enable-pls-check' } + Write-Log "Executando: azcmagent $($checkArgs -join ' ')" Info -NoCount + Save-LogBuffer # garante ordem: cabecalho antes do output do binario try { - # Measure response time and check for 401 - $response_time = Measure-Command { - $response = Invoke-WebRequest -Uri "https://$trimmedEndpoint" -Method Get - } - - if ($response.StatusCode -eq 401) { - # If status code is 401, treat it as expected and log it as such - $httpResult = "Expected (401)" - $httpOutput = "Success: HTTP request succeeded for $($trimmedEndpoint): $($httpResult) - Response time: $($response_time.TotalSeconds) seconds" - Write-Host $httpOutput -ForegroundColor Green - $httpOutput | Out-File -FilePath $logFilePath -Append - } else { - # For other status codes, log the actual status code - $httpResult = "Unexpected Status: $($response.StatusCode)" - $httpOutput = "Success: HTTP request succeeded for $($trimmedEndpoint): $($httpResult) - Response time: $($response_time.TotalSeconds) seconds" - Write-Host $httpOutput -ForegroundColor Green - $httpOutput | Out-File -FilePath $logFilePath -Append - } - } catch { - # Handle exceptions and log them - if ($_.Exception.Message -like "*401*") { - # If 401 is encountered in the exception message, treat it as expected - $httpResult = "Expected (401)" - $httpError = "Success: HTTP request succeeded for $($trimmedEndpoint) - $httpResult" - Write-Host $httpError -ForegroundColor Green - $httpError | Out-File -FilePath $logFilePath -Append - } else { - # For other exceptions, log the error message - $httpResult = "Error: $_" - $httpError = "Error: HTTP request failed for $($trimmedEndpoint) - $httpResult" - Write-Host $httpError -ForegroundColor Red - $httpError | Out-File -FilePath $logFilePath -Append - } - } - - "----------------------------------------" | Out-File -FilePath $logFilePath -Append -} - -# Public and Private Azure Arc check -$publicAzureArcMessage = "Running public Azure Arc check..." -$publicAzureArcMessage | Out-File -FilePath $logFilePath -Append - -# Check if azcmagent.exe exists in the default path -$azcmagentPath = Join-Path $env:PROGRAMFILES "AzureConnectedMachineAgent\azcmagent.exe" -if (Test-Path $azcmagentPath) { - $logMessage = "Running: & $azcmagentPath check --location $($region) --cloud AzureCloud --extensions sql --enable-pls-check" - $logMessage | Out-File -FilePath $logFilePath -Append - - # Execute azcmagent from the correct path - $azcmAgentResult = & $azcmagentPath check --location $($region) --cloud AzureCloud --extensions sql --enable-pls-check - $azcmAgentResult | Out-File -FilePath $logFilePath -Append -} else { - $errorMessage = "Error: azcmagent.exe not found in $azcmagentPath" - $errorMessage | Out-File -FilePath $logFilePath -Append -} - -# Write end of the log -"Script finished at $(Get-Date)" | Out-File -FilePath $logFilePath -Append + $out = & $azcm @checkArgs 2>&1 + Add-Content -Path $LogFilePath -Value $out + if ($LASTEXITCODE -eq 0) { + Write-Log 'azcmagent check concluido (exit 0).' OK + } + else { + Write-Log "azcmagent check terminou com exit $LASTEXITCODE." Fail + } + } + catch { + Write-Log "azcmagent check falhou: $($_.Exception.Message)" Fail + } +} +else { + Write-Log 'azcmagent.exe nao encontrado - pulando check.' Warn +} + +# --------------------------------------------------------------------------- +# Resumo +# --------------------------------------------------------------------------- +[void]$script:LogBuffer.Add('=' * 60) +Write-Log ("Resumo: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` + $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) Info -NoCount +Write-Log "Script finished at $(Get-Date -Format o)" Info -NoCount +Save-LogBuffer + +# --------------------------------------------------------------------------- +# Tabela de resultados (console + log) +# --------------------------------------------------------------------------- +$tableObjects = $script:Results | ForEach-Object { [pscustomobject]$_ } + +Write-Host '' +Write-Host '=================== SUMARIO ===================' -ForegroundColor Cyan + +$rowFormat = "{0,-5} {1,-55} {2,-26} {3,-8} {4,-5} {5,-5} {6,-12} {7,-9}" +Write-Host ($rowFormat -f 'Group', 'Endpoint', 'IP', 'Type', 'DNS', 'TCP', 'HTTP', 'Latency') -ForegroundColor Cyan +Write-Host ($rowFormat -f ('-' * 5), ('-' * 55), ('-' * 26), ('-' * 8), ('-' * 5), ('-' * 5), ('-' * 12), ('-' * 9)) -ForegroundColor DarkGray + +foreach ($r in $tableObjects) { + $hasFail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') + $hasWarn = ($r.DNS -eq 'WARN') + $color = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } + Write-Host ($rowFormat -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) -ForegroundColor $color +} + +Write-Host '' +Write-Host ("Totais: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` + $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) -ForegroundColor Cyan + +if ($script:EffectiveProxy) { + Write-Host "Proxy utilizado: $($script:EffectiveProxy)" -ForegroundColor DarkGray +} + +# Append tabela ao arquivo de log +$tableString = $tableObjects | Format-Table -AutoSize | Out-String +Add-Content -Path $LogFilePath -Value '' +Add-Content -Path $LogFilePath -Value '=================== SUMARIO ===================' +Add-Content -Path $LogFilePath -Value $tableString.TrimEnd() +Add-Content -Path $LogFilePath -Value ("Totais: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` + $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) + +Write-Host "`nLog completo: $LogFilePath" -ForegroundColor Cyan +exit ([int]($script:Stats.Fail -gt 0)) From 983635045823cf9874e99396d8019d0e7c51b741 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:01:07 -0300 Subject: [PATCH 03/10] Fix formatting in ArcEndpointCheck.ps1 --- script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index 01cf00b4..991e30c9 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -861,7 +861,7 @@ Write-Host ($rowFormat -f ('-' * 5), ('-' * 55), ('-' * 26), ('-' * 8), ('-' * 5 foreach ($r in $tableObjects) { $hasFail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') - $hasWarn = ($r.DNS -eq 'WARN') + $hasWarn = ($r.DNS -eq 'WARN') $color = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } Write-Host ($rowFormat -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) -ForegroundColor $color } From 6bf2090dda2fd81431d37ec8b885fcc780471f39 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:11:52 -0300 Subject: [PATCH 04/10] Update _index.md with new endpoint check details Added details about dynamic endpoint allowlist and exit code. --- script_automation/arc_endpoint_check/_index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script_automation/arc_endpoint_check/_index.md b/script_automation/arc_endpoint_check/_index.md index 982a81a6..2bccc16c 100644 --- a/script_automation/arc_endpoint_check/_index.md +++ b/script_automation/arc_endpoint_check/_index.md @@ -26,6 +26,10 @@ script is required) and adds: reported but never applied automatically, matching the agent's behavior. - **Optional extension endpoint groups**: SQL Server enabled by Azure Arc, Azure Monitor Agent (AMA), Microsoft Defender for Endpoint (MDE), and Windows Admin Center (WAC). +- **Dynamic endpoint allowlist**: in Public mode the script also queries the + `guestnotificationservice` allowlist for the region and validates those endpoints + (primary namespaces only). Failures here are treated as warnings and never affect the + exit code, since this list is auxiliary. - **IPv4-first DNS resolution** (Private Link uses A records), avoiding false "public" classification when public AAAA records coexist. - A machine-readable **exit code** (`0` = all checks OK, `1` = at least one failure). @@ -86,3 +90,4 @@ private. Examples: # Full pre-onboarding validation of all extension endpoints .\ArcEndpointCheck.ps1 -Mode Private -CheckIncludeAll -Verbose -IncludeSQL -IncludeAMA -IncludeMDE -IncludeWAC +``` From e331c442d569aedf464d9c4f407225f02203289a Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:22:35 -0300 Subject: [PATCH 05/10] Translate comments and improve proxy handling Updated comments and logging messages to English for better clarity and consistency. Adjusted proxy handling and validation functions. --- .../arc_endpoint_check/ArcEndpointCheck.ps1 | 382 ++++++++++-------- 1 file changed, 221 insertions(+), 161 deletions(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index 991e30c9..f1f45970 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -124,7 +124,7 @@ param( # Setup # --------------------------------------------------------------------------- $ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' # acelera Invoke-WebRequest e Test-NetConnection +$ProgressPreference = 'SilentlyContinue' # speeds up Invoke-WebRequest and Test-NetConnection $logDir = Split-Path -Path $LogFilePath -Parent if ($logDir -and -not (Test-Path $logDir)) { @@ -136,6 +136,26 @@ $script:Stats = [ordered]@{ OK = 0; Fail = 0; Warn = 0 } $script:LogBuffer = [System.Collections.ArrayList]::new() $script:Results = [System.Collections.ArrayList]::new() +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Test-IsValidProxyUri { + <# + .SYNOPSIS + Returns $true only when the input is a well-formed absolute http:// or + https:// URI. Rejects informational strings that azcmagent returns when + the proxy is not configured (e.g. "proxy.url has not been set"). + #> + param([string]$Candidate) + if (-not $Candidate) { return $false } + $parsed = $null + return ( + [System.Uri]::TryCreate($Candidate, [System.UriKind]::Absolute, [ref]$parsed) -and + $parsed.Scheme -in @('http', 'https') + ) +} + function Add-Result { param( [Parameter(Mandatory)] [string]$Endpoint, @@ -232,84 +252,101 @@ function Invoke-WebRequestSafe { $params['ProxyUseDefaultCredentials'] = $true } elseif ($PSVersionTable.PSVersion.Major -ge 6) { - # PS 6+: espelha o agente, que IGNORA o proxy system-wide. Em PS 5.1 o - # mesmo efeito e obtido neutralizando o DefaultWebProxy do .NET no setup. + # PS 6+: mirror the agent, which IGNORES the system-wide proxy. On PS 5.1 the + # same effect is achieved by neutralizing .NET's DefaultWebProxy during setup. $params['NoProxy'] = $true } return Invoke-WebRequest @params } # --------------------------------------------------------------------------- -# Detecao e exibicao de proxy +# Proxy detection and display # --------------------------------------------------------------------------- $script:EffectiveProxy = $null function Get-ProxyDiagnostics { - Write-Log '=== DIAGNOSTICO DE PROXY ===' Info -NoCount + Write-Log '=== PROXY DIAGNOSTICS ===' Info -NoCount [void]$script:LogBuffer.Add('') - # Precedencia do proxy efetivo (usado nos testes HTTP deste script) — espelha - # o comportamento do Azure Connected Machine agent no Windows: - # 1) -ProxyUrl (override explicito do operador) - # 2) azcmagent proxy.url (config do agente — TEM PRECEDENCIA sobre env vars) + # Effective proxy precedence (used by this script's HTTP tests) — mirrors the + # behavior of the Azure Connected Machine agent on Windows: + # 1) -ProxyUrl (explicit operator override) + # 2) azcmagent proxy.url (agent config — TAKES PRECEDENCE over env vars) # 3) HTTPS_PROXY (env) (system-wide) - # O agente IGNORA o proxy system-wide do Windows (WinHTTP/WinINET); por isso o - # WinHTTP abaixo e apenas REPORTADO, nunca aplicado automaticamente. + # The agent IGNORES the Windows system-wide proxy (WinHTTP/WinINET); that is why + # the WinHTTP value below is only REPORTED, never applied automatically. # Ref: https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings - # 1) Parametro -ProxyUrl + # 1) -ProxyUrl parameter (validated as an absolute http/https URI) if ($ProxyUrl) { - Write-Log "Proxy via parametro: $ProxyUrl" Info -NoCount - $script:EffectiveProxy = $ProxyUrl + if (Test-IsValidProxyUri -Candidate $ProxyUrl) { + Write-Log "Proxy from parameter: $ProxyUrl" Info -NoCount + $script:EffectiveProxy = $ProxyUrl + } + else { + Write-Log "Proxy from parameter INVALID (expected http:// or https://): $ProxyUrl" Warn + } } - # WinHTTP (apenas informativo — o agente ignora o proxy system-wide) - # Nota: o parse do 'netsh' abaixo depende de Windows em INGLES. Em SO - # localizado (ex.: pt-BR) o regex pode nao casar e reportar 'Direct' - # mesmo havendo proxy configurado no WinHTTP. + # WinHTTP (informational only — the agent ignores the system-wide proxy) + # Bilingual regex: EN "Proxy Server(s)" / pt-BR "Servidor(es) Proxy" try { $winhttp = netsh winhttp show proxy 2>$null $winhttpText = ($winhttp | Out-String).Trim() - if ($winhttpText -match 'Proxy Server\(s\)\s*:\s*(.+)') { - $winhttpProxy = $Matches[1].Trim() - Write-Log "WinHTTP Proxy: $winhttpProxy (informativo — o agente ignora o proxy system-wide)" Info -NoCount + if ($winhttpText -match 'Proxy Server\(s\)\s*:\s*(.+)|Servidor\(es\) Proxy\s*:\s*(.+)') { + $winhttpProxy = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() + Write-Log "WinHTTP Proxy: $winhttpProxy (informational — the agent ignores the system-wide proxy)" Info -NoCount } else { - Write-Log 'WinHTTP Proxy: Direct (sem proxy)' Info -NoCount + Write-Log 'WinHTTP Proxy: Direct (no proxy)' Info -NoCount } - if ($winhttpText -match 'Bypass List\s*:\s*(.+)') { - Write-Log "WinHTTP Bypass: $($Matches[1].Trim())" Info -NoCount + if ($winhttpText -match 'Bypass List\s*:\s*(.+)|Lista de bypass\s*:\s*(.+)') { + $bypassVal = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() + Write-Log "WinHTTP Bypass: $bypassVal" Info -NoCount } } catch { - Write-Log "WinHTTP: nao foi possivel consultar ($($_.Exception.Message))" Warn + Write-Log "WinHTTP: unable to query ($($_.Exception.Message))" Warn } - # 2) azcmagent config (proxy.url TEM PRECEDENCIA sobre HTTPS_PROXY) + # 2) azcmagent config (proxy.url TAKES PRECEDENCE over HTTPS_PROXY) + # IMPORTANT: azcmagent returns informational phrases when the value is not + # configured (e.g. "proxy.url has not been set"). We validate with + # Test-IsValidProxyUri to accept ONLY real http/https URIs. $azcm = Get-AzcmagentPath if ($azcm) { try { - $proxyUrl = & $azcm config get proxy.url 2>$null - if ($proxyUrl -and $proxyUrl.Trim()) { - Write-Log "azcmagent proxy.url: $($proxyUrl.Trim())" Info -NoCount + $rawProxyUrl = & $azcm config get proxy.url 2>$null + $rawProxyUrlTrimmed = if ($rawProxyUrl) { ($rawProxyUrl | Out-String).Trim() } else { '' } + + if (Test-IsValidProxyUri -Candidate $rawProxyUrlTrimmed) { + Write-Log "azcmagent proxy.url: $rawProxyUrlTrimmed" Info -NoCount if (-not $script:EffectiveProxy) { - $script:EffectiveProxy = $proxyUrl.Trim() + $script:EffectiveProxy = $rawProxyUrlTrimmed } } else { - Write-Log 'azcmagent proxy.url: (nao configurado)' Info -NoCount + # Informational message (e.g. "proxy.url has not been set") + if ($rawProxyUrlTrimmed) { + Write-Log "azcmagent proxy.url: $rawProxyUrlTrimmed" Info -NoCount + } + else { + Write-Log 'azcmagent proxy.url: (not configured)' Info -NoCount + } } + $bypass = & $azcm config get proxy.bypass 2>$null - if ($bypass -and $bypass.Trim()) { - Write-Log "azcmagent proxy.bypass: $($bypass.Trim())" Info -NoCount + $bypassTrimmed = if ($bypass) { ($bypass | Out-String).Trim() } else { '' } + if ($bypassTrimmed) { + Write-Log "azcmagent proxy.bypass: $bypassTrimmed" Info -NoCount } } catch { - Write-Log "azcmagent config: falha ao consultar ($($_.Exception.Message))" Warn + Write-Log "azcmagent config: failed to query ($($_.Exception.Message))" Warn } } - # 3) Environment variables (verifica Machine -> Process -> User) + # 3) Environment variables (checks Machine -> Process -> User) $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Process') } if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'User') } @@ -319,28 +356,33 @@ function Get-ProxyDiagnostics { if ($envProxy) { Write-Log "Env HTTPS_PROXY: $envProxy" Info -NoCount if (-not $script:EffectiveProxy) { - $script:EffectiveProxy = $envProxy + if (Test-IsValidProxyUri -Candidate $envProxy) { + $script:EffectiveProxy = $envProxy + } + else { + Write-Log "Env HTTPS_PROXY INVALID (expected http:// or https://): $envProxy" Warn + } } } else { - Write-Log 'Env HTTPS_PROXY: (nao definido)' Info -NoCount + Write-Log 'Env HTTPS_PROXY: (not set)' Info -NoCount } if ($envNoProxy) { Write-Log "Env NO_PROXY: $envNoProxy" Info -NoCount } if ($script:EffectiveProxy) { - Write-Log "Proxy efetivo para testes HTTP: $($script:EffectiveProxy)" Info -NoCount + Write-Log "Effective proxy for HTTP tests: $($script:EffectiveProxy)" Info -NoCount } else { - Write-Log 'Proxy efetivo: Direct (sem proxy — testes HTTP vao direto, como o agente)' Info -NoCount + Write-Log 'Effective proxy: Direct (no proxy — HTTP tests go direct, like the agent)' Info -NoCount } [void]$script:LogBuffer.Add('') } # --------------------------------------------------------------------------- -# Deteccao automatica Public vs Private +# Automatic Public vs Private detection # --------------------------------------------------------------------------- function Get-AzcmagentPath { $candidate = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' @@ -356,7 +398,7 @@ function Test-IsPrivateIp { } catch { return $false } - # RFC1918 + 100.64/10 (CGNAT, comum em redes corporativas) + # RFC1918 + 100.64/10 (CGNAT, common in corporate networks) return ($bytes[0] -eq 10) -or ($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or @@ -364,7 +406,7 @@ function Test-IsPrivateIp { } function Resolve-ArcMode { - Write-Log 'Detectando modo Arc (Public/Private)...' Info -NoCount + Write-Log 'Detecting Arc mode (Public/Private)...' Info -NoCount # 1) Via azcmagent show -j $azcm = Get-AzcmagentPath @@ -373,52 +415,52 @@ function Resolve-ArcMode { $json = & $azcm show -j 2>$null | ConvertFrom-Json $pls = $json.privateLinkScope if ($pls) { - Write-Log "azcmagent reporta privateLinkScope: $pls" Info -NoCount + Write-Log "azcmagent reports privateLinkScope: $pls" Info -NoCount return 'Private' } else { - # NAO conclui Public aqui: cai para o heuristico DNS abaixo. O - # Private Link pode ser "via DNS" (Private DNS Zones) sem o agente - # expor o PLS localmente em 'azcmagent show -j'. - Write-Log 'azcmagent nao reporta privateLinkScope; confirmando via DNS...' Info -NoCount + # Do NOT conclude Public here: fall through to the DNS heuristic below. + # Private Link can be "DNS-based" (Private DNS Zones) without the agent + # exposing the PLS locally in 'azcmagent show -j'. + Write-Log 'azcmagent does not report privateLinkScope; confirming via DNS...' Info -NoCount } } catch { - Write-Log "Falha ao consultar azcmagent show -j: $($_.Exception.Message). Caindo para fallback DNS." Warn + Write-Log "Failed to query azcmagent show -j: $($_.Exception.Message). Falling back to DNS." Warn } } else { - Write-Log 'azcmagent.exe nao encontrado. Usando fallback DNS.' Warn + Write-Log 'azcmagent.exe not found. Using DNS fallback.' Warn } - # 2) Fallback: resolver gbl.his.arc.azure.com + # 2) Fallback: resolve gbl.his.arc.azure.com try { $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop $ip = ($dns | Where-Object IPAddress | Select-Object -First 1).IPAddress if (Test-IsPrivateIp -Ip $ip) { - Write-Log "gbl.his.arc.azure.com resolve para IP privado ($ip) -> Private Link" Info -NoCount + Write-Log "gbl.his.arc.azure.com resolves to a private IP ($ip) -> Private Link" Info -NoCount return 'Private' } else { - Write-Log "gbl.his.arc.azure.com resolve para IP publico ($ip) -> Public" Info -NoCount + Write-Log "gbl.his.arc.azure.com resolves to a public IP ($ip) -> Public" Info -NoCount return 'Public' } } catch { - Write-Log 'Nao foi possivel resolver gbl.his.arc.azure.com - assumindo Public.' Warn + Write-Log 'Could not resolve gbl.his.arc.azure.com - assuming Public.' Warn return 'Public' } } # --------------------------------------------------------------------------- -# Detecao de modo e proxy +# Mode and proxy detection # --------------------------------------------------------------------------- Get-ProxyDiagnostics -# Alinhamento com o agente: o Azure Connected Machine agent IGNORA o proxy -# system-wide do Windows (WinINET/WinHTTP). Se nenhum proxy efetivo foi -# detectado, neutralizamos o DefaultWebProxy do .NET (PS 5.1) para que os -# testes HTTP tambem vao direto. Em PS 6+ isso e feito via -NoProxy. +# Alignment with the agent: the Azure Connected Machine agent IGNORES the Windows +# system-wide proxy (WinINET/WinHTTP). If no effective proxy was detected, we +# neutralize .NET's DefaultWebProxy (PS 5.1) so that the HTTP tests also go direct. +# On PS 6+ this is done via -NoProxy. if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { try { [System.Net.WebRequest]::DefaultWebProxy = $null } catch { } } @@ -426,19 +468,19 @@ if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { if ($Mode -eq 'Auto') { $Mode = Resolve-ArcMode } -Write-Log "Modo selecionado: $Mode | Regiao: $Region" Info -NoCount +Write-Log "Selected mode: $Mode | Region: $Region" Info -NoCount -# Reset stats: fase de testes comeca aqui (detecao nao conta) +# Reset stats: the test phase starts here (detection does not count) $script:Stats.OK = 0 $script:Stats.Fail = 0 $script:Stats.Warn = 0 # --------------------------------------------------------------------------- -# Endpoints — organizados por grupo funcional +# Endpoints — organized by functional group # --------------------------------------------------------------------------- -# Endpoints que PODEM resolver para IP privado via Azure Private Link Scope. -# Tudo que NAO esta nesta lista e sempre publico — nao gerar WARN em modo Private. +# Endpoints that CAN resolve to a private IP via Azure Private Link Scope. +# Everything NOT in this list is always public — do not raise a WARN in Private mode. $canBePrivateEndpoints = @( 'gbl.his.arc.azure.com' 'agentserviceapi.guestconfiguration.azure.com' @@ -446,62 +488,62 @@ $canBePrivateEndpoints = @( 'global.handler.control.monitor.azure.com' ) -# Core Arc (obrigatorios) — alinhado a network-requirements do Connected Machine agent. +# Core Arc (required) — aligned with the Connected Machine agent network-requirements. # Doc: https://learn.microsoft.com/azure/azure-arc/servers/network-requirements $coreEndpoints = @( - # AAD / Identity (sempre; Public) + # AAD / Identity (always; Public) 'login.windows.net' 'login.microsoftonline.com' 'pas.windows.net' - # ARM (conexao/desconexao; Public salvo Resource Management Private Link) + # ARM (connect/disconnect; Public unless Resource Management Private Link) 'management.azure.com' - # Arc HIMDS (sempre; Private via PLS) + # Arc HIMDS (always; Private via PLS) 'gbl.his.arc.azure.com' - # Guest Configuration / gestao de extensoes (sempre; Private via PLS) + # Guest Configuration / extension management (always; Private via PLS) 'agentserviceapi.guestconfiguration.azure.com' - # Instalacao/atualizacao do agente (Public) + # Agent install/update (Public) 'packages.microsoft.com' 'download.microsoft.com' - # Telemetria (opcional; NAO usado em agentes 1.24+; Public) + # Telemetry (optional; NOT used on agents 1.24+; Public) 'dc.services.visualstudio.com' ) -# SQL endpoints (opcional via -IncludeSQL) — Arc-enabled SQL Server. -# Doc: network-requirements + sql/.../data-collection. Todos Public; TLS 1.2/1.3. +# SQL endpoints (optional via -IncludeSQL) — Arc-enabled SQL Server. +# Doc: network-requirements + sql/.../data-collection. All Public; TLS 1.2/1.3. $sqlEndpoints = @() if ($IncludeSQL) { $sqlEndpoints = @( - # Data processing service + telemetria (extensoes a partir de mar/2024) + # Data processing service + telemetry (extensions from Mar/2024 onward) "dataprocessingservice.$Region.arcdataservices.com" "telemetry.$Region.arcdataservices.com" - # Legado: usado por extensoes ate 13/fev/2024 + # Legacy: used by extensions until Feb 13, 2024 "san-af-$Region-prod.azurewebsites.net" - # Autenticacao Microsoft Entra do Arc SQL (Public). So necessario se usar - # Entra auth; NAO e endpoint core do agente. Reachable direto, mas pode ser - # bloqueado em proxy split-tunnel -> apenas DNS/TCP (sem HTTP probe). + # Arc SQL Microsoft Entra authentication (Public). Only needed when using + # Entra auth; NOT a core agent endpoint. Reachable directly, but may be + # blocked on a split-tunnel proxy -> DNS/TCP only (no HTTP probe). 'graph.microsoft.com' ) } -# AMA endpoints (opcional via -IncludeAMA) — Azure Monitor Agent. -# Doc: azure-monitor-agent-network-configuration. Endpoints .ods e -# .ingest.monitor exigem IDs especificos -> nao testaveis genericamente. +# AMA endpoints (optional via -IncludeAMA) — Azure Monitor Agent. +# Doc: azure-monitor-agent-network-configuration. The .ods and +# .ingest.monitor endpoints require specific IDs -> not generically testable. $amaEndpoints = @() if ($IncludeAMA) { $amaEndpoints = @( 'global.handler.control.monitor.azure.com' # control service 'global.prod.microsoftmetrics.com' # metrics service - "$Region.handler.control.monitor.azure.com" # DCRs da regiao - "$Region.monitoring.azure.com" # custom metrics (opcional) + "$Region.handler.control.monitor.azure.com" # regional DCRs + "$Region.monitoring.azure.com" # custom metrics (optional) ) } -# MDE endpoints (opcional via -IncludeMDE) +# MDE endpoints (optional via -IncludeMDE) $mdeEndpoints = @() if ($IncludeMDE) { $mdeEndpoints = @( @@ -510,33 +552,34 @@ if ($IncludeMDE) { ) } -# WAC endpoints (opcional via -IncludeWAC) +# WAC endpoints (optional via -IncludeWAC) +# Note: 'pas.windows.net' is already in $coreEndpoints and $endpointGroupMap +# preserves Core precedence, avoiding duplication in the summary. $wacEndpoints = @() if ($IncludeWAC) { $wacEndpoints = @( "$Region.service.waconazure.com" - 'pas.windows.net' ) } -# Endpoints que respondem HTTP (validacao L7 — 200/400/401/403/404 = reachable). -# NAO inclui graph.microsoft.com: e endpoint de Entra auth do Arc SQL (opcional) e -# costuma ser bloqueado em proxy split-tunnel; o proprio teste oficial de -# conectividade do Arc SQL valida apenas DPS + telemetria. +# Endpoints that respond to HTTP (L7 validation — 200/400/401/403/404 = reachable). +# Does NOT include graph.microsoft.com: it is the Arc SQL Entra auth endpoint (optional) +# and is often blocked on a split-tunnel proxy; the official Arc SQL connectivity +# test itself validates only DPS + telemetry. $httpProbeEndpoints = @( 'login.windows.net' 'login.microsoftonline.com' 'management.azure.com' ) if ($IncludeSQL) { - # Alinhado ao teste oficial do Arc SQL: DPS espera 200; telemetria espera 401 - # (ambos tratados como reachable aqui). + # Aligned with the official Arc SQL test: DPS expects 200; telemetry expects 401 + # (both treated as reachable here). $httpProbeEndpoints += "dataprocessingservice.$Region.arcdataservices.com" $httpProbeEndpoints += "telemetry.$Region.arcdataservices.com" } -# Mapeia grupo por endpoint para o sumario. Core tem PRECEDENCIA: se um endpoint -# aparece em mais de um grupo (ex.: pas.windows.net em Core e WAC), mantemos 'Core'. +# Maps a group per endpoint for the summary. Core has PRECEDENCE: if an endpoint +# appears in more than one group (e.g. pas.windows.net in Core and WAC), we keep 'Core'. $endpointGroupMap = @{} foreach ($ep in $coreEndpoints) { $endpointGroupMap[$ep] = 'Core' } foreach ($ep in $sqlEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'SQL' } } @@ -544,20 +587,20 @@ foreach ($ep in $amaEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { foreach ($ep in $mdeEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'MDE' } } foreach ($ep in $wacEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'WAC' } } -# Dynamic allowlist (somente em modo publico; em PLS o trafego e via PE) +# Dynamic allowlist (Public mode only; under PLS traffic goes via PE) $dynamicEndpoints = @() if ($Mode -eq 'Public') { try { - Write-Log 'Buscando endpoints dinamicos do guestnotificationservice...' Info -NoCount + Write-Log 'Fetching dynamic endpoints from guestnotificationservice...' Info -NoCount $uri = "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$Region" $resp = Invoke-WebRequestSafe -Uri $uri $dynamicEndpoints = @($resp.Content | ConvertFrom-Json) | Where-Object { $_ } if ($dynamicEndpoints.Count -gt 0) { $totalGNS = $dynamicEndpoints.Count - # Filtrar: manter apenas endpoints primarios da regiao. - # Namespaces primarios contem 'p-' (ex: 1p-, 2p-), secundarios contem 's-'. - # Extrair cluster IDs dos primarios e filtrar children por eles. + # Filter: keep only the region's primary endpoints. + # Primary namespaces contain 'p-' (e.g. 1p-, 2p-), secondary contain 's-'. + # Extract cluster IDs from the primaries and filter children by them. $primaryClusterIds = [System.Collections.ArrayList]::new() foreach ($dep in $dynamicEndpoints) { if ($dep -match '^azgn-.+\dp-.+?-(\w+)\.servicebus') { @@ -569,7 +612,7 @@ if ($Mode -eq 'Public') { $filteredGNS = [System.Collections.ArrayList]::new() foreach ($dep in $dynamicEndpoints) { if ($dep -match '^azgn-') { - [void]$filteredGNS.Add($dep) # sempre manter namespace-level + [void]$filteredGNS.Add($dep) # always keep namespace-level } else { foreach ($cid in $primaryClusterIds) { @@ -583,14 +626,14 @@ if ($Mode -eq 'Public') { $skipped = $totalGNS - $filteredGNS.Count $dynamicEndpoints = @($filteredGNS) if ($skipped -gt 0) { - Write-Log "Endpoints dinamicos obtidos: $totalGNS total, $($filteredGNS.Count) primarios ($skipped secundarios filtrados)" OK + Write-Log "Dynamic endpoints obtained: $totalGNS total, $($filteredGNS.Count) primary ($skipped secondary filtered out)" OK } else { - Write-Log "Endpoints dinamicos obtidos: $totalGNS endpoint(s)" OK + Write-Log "Dynamic endpoints obtained: $totalGNS endpoint(s)" OK } } else { - Write-Log "Endpoints dinamicos obtidos: $totalGNS endpoint(s)" OK + Write-Log "Dynamic endpoints obtained: $totalGNS endpoint(s)" OK } foreach ($dep in $dynamicEndpoints) { @@ -599,14 +642,14 @@ if ($Mode -eq 'Public') { } } catch { - # Allowlist dinamica e AUXILIAR: sua indisponibilidade nao deve derrubar o - # exit code (WARN, nao FAIL). Comum ao forcar -Mode Public num host que, na - # pratica, roteia o GNS via Private Link / firewall. - Write-Log "Falha ao obter endpoints dinamicos (allowlist auxiliar): $($_.Exception.Message)" Warn + # The dynamic allowlist is AUXILIARY: its unavailability must not break the + # exit code (WARN, not FAIL). Common when forcing -Mode Public on a host that, + # in practice, routes GNS via Private Link / firewall. + Write-Log "Failed to obtain dynamic endpoints (auxiliary allowlist): $($_.Exception.Message)" Warn } } else { - Write-Log 'Modo Private: pulando consulta de allowlist publico.' Info -NoCount + Write-Log 'Private mode: skipping public allowlist query.' Info -NoCount } $allEndpoints = @( @@ -616,21 +659,23 @@ $allEndpoints = @( Select-Object -Unique ) -Write-Log "Total de endpoints a testar: $($allEndpoints.Count)" Info -NoCount +Write-Log "Total endpoints to test: $($allEndpoints.Count)" Info -NoCount [void]$script:LogBuffer.Add('') # --------------------------------------------------------------------------- -# Testes: DNS + TCP/443 (validacao de coerencia com o modo detectado) +# Tests: DNS + TCP/443 (consistency check against the detected mode) # --------------------------------------------------------------------------- foreach ($ep in $allEndpoints) { $ep = $ep.Trim() if (-not $ep) { continue } + Write-Verbose "Testing: $ep" + [void]$script:LogBuffer.Add('-' * 60) $group = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Dyn' } Add-Result -Endpoint $ep -Group $group - # DNS (com 1 retry em falha transitoria, ex.: SERVFAIL ao resolver muitos nomes) + # DNS (with 1 retry on a transient failure, e.g. SERVFAIL when resolving many names) $dns = $null $dnsErr = $null foreach ($attempt in 1..2) { @@ -638,12 +683,12 @@ foreach ($ep in $allEndpoints) { catch { $dnsErr = $_; if ($attempt -lt 2) { Start-Sleep -Milliseconds 300 } } } if ($dnsErr) { - # Endpoints dinamicos (GNS) sao AUXILIARES: uma falha de DNS neles vira WARN - # (nao FAIL), pois um SERVFAIL transitorio ao resolver dezenas de nomes - # 'servicebus' nao deve derrubar o exit code. Demais grupos permanecem FAIL. + # Dynamic endpoints (GNS) are AUXILIARY: a DNS failure on them becomes WARN + # (not FAIL), because a transient SERVFAIL when resolving dozens of + # 'servicebus' names must not break the exit code. Other groups stay FAIL. $existingD = $script:Results | Where-Object { $_.Endpoint -eq $ep } if ($group -eq 'GNS') { - Write-Log "DNS WARN $ep - $($dnsErr.Exception.Message) (endpoint dinamico/auxiliar)" Warn + Write-Log "DNS WARN $ep - $($dnsErr.Exception.Message) (dynamic/auxiliary endpoint)" Warn if ($existingD) { $existingD.DNS = 'WARN' } } else { @@ -653,10 +698,10 @@ foreach ($ep in $allEndpoints) { continue } - # Preferir IPv4 (registro A): o Azure Private Link e a maioria dos endpoints - # Arc sao resolvidos por A-record. Um AAAA (IPv6) publico pode coexistir com - # o A privado; se escolhido, causa classificacao PUBLIC incorreta e testes - # por um caminho IPv6 possivelmente inexistente/nao roteado. + # Prefer IPv4 (A record): Azure Private Link and most Arc endpoints are resolved + # by A record. A public AAAA (IPv6) may coexist with the private A; if chosen, it + # causes an incorrect PUBLIC classification and tests over a possibly + # nonexistent/unrouted IPv6 path. $rec = $dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1 if (-not $rec) { $rec = $dns | Where-Object IPAddress | Select-Object -First 1 } $ip = $rec.IPAddress @@ -666,9 +711,9 @@ foreach ($ep in $allEndpoints) { $existing = $script:Results | Where-Object { $_.Endpoint -eq $ep } if ($existing) { $existing.IP = $ip; $existing.Type = $kind } - # Alerta de mismatch DNS x modo - # Apenas endpoints em $canBePrivateEndpoints devem resolver para IP privado. - # Todos os outros (AAD, ARM, CDN, SQL, AMA, MDE, WAC, GNS) sao sempre publicos. + # DNS vs mode mismatch alert + # Only endpoints in $canBePrivateEndpoints should resolve to a private IP. + # All others (AAD, ARM, CDN, SQL, AMA, MDE, WAC, GNS) are always public. $canBePrivate = $canBePrivateEndpoints -contains $ep $mismatch = $false if ($Mode -eq 'Private' -and $kind -eq 'PUBLIC' -and $canBePrivate) { @@ -678,7 +723,7 @@ foreach ($ep in $allEndpoints) { $mismatch = $true } if ($mismatch) { - Write-Log "DNS WARN $ep -> $ip [$kind] (esperado para modo $Mode era o oposto)" Warn + Write-Log "DNS WARN $ep -> $ip [$kind] (expected the opposite for $Mode mode)" Warn $existing2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } if ($existing2) { $existing2.DNS = 'WARN' } } @@ -688,7 +733,7 @@ foreach ($ep in $allEndpoints) { if ($existing2) { $existing2.DNS = 'OK' } } - # TCP/443 (TcpClient com timeout — muito mais rapido que Test-NetConnection) + # TCP/443 (TcpClient with timeout — much faster than Test-NetConnection) $tcpSw = [System.Diagnostics.Stopwatch]::StartNew() $tcpOk = Test-TcpPort -ComputerName $ep -Port 443 -TimeoutMs 5000 $tcpSw.Stop() @@ -700,34 +745,37 @@ foreach ($ep in $allEndpoints) { if ($existing3) { $existing3.TCP = 'OK'; $existing3.Latency = "${latencyMs}ms" } } else { - Write-Log "TCP FAIL ${ep}:443 (timeout/recusado)" Fail + Write-Log "TCP FAIL ${ep}:443 (timeout/refused)" Fail if ($existing3) { $existing3.TCP = 'FAIL'; $existing3.Latency = 'timeout' } } } # --------------------------------------------------------------------------- -# Testes HTTP (401/403/400 sao considerados sucesso: endpoint exige auth) -# Detecta azcmagent proxy.bypass para pular HTTP tests em endpoints bypassados +# HTTP tests (401/403/400 are treated as success: endpoint requires auth) +# Detects azcmagent proxy.bypass to skip HTTP tests on bypassed endpoints # --------------------------------------------------------------------------- $proxyBypassCategories = @() $azcmPath = Get-AzcmagentPath if ($azcmPath -and $script:EffectiveProxy) { try { $bypassRaw = & $azcmPath config get proxy.bypass 2>$null - if ($bypassRaw -and $bypassRaw.Trim()) { - $bypassClean = $bypassRaw.Trim().Trim('[', ']') - $proxyBypassCategories = $bypassClean -split ',' | ForEach-Object { $_.Trim() } + $bypassRawStr = if ($bypassRaw) { ($bypassRaw | Out-String).Trim() } else { '' } + if ($bypassRawStr) { + $bypassClean = $bypassRawStr.Trim('[', ']') + $proxyBypassCategories = $bypassClean -split ',' | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } } } catch { } } -# Mapa de categorias de bypass -> endpoints afetados (conforme doc oficial: +# Map of bypass categories -> affected endpoints (per the official doc: # https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings). -# IMPORTANTE: 'graph.microsoft.com' NAO e coberto por nenhum bypass — o agente -# usa o proxy para ele; por isso ele NAO deve ser pulado nos testes HTTP. -# 'ArcData' e valido a partir do agente 1.36; em versoes anteriores os endpoints -# arcdataservices ficavam sob a categoria 'Arc'. +# IMPORTANT: 'graph.microsoft.com' is NOT covered by any bypass — the agent +# uses the proxy for it; therefore it must NOT be skipped in the HTTP tests. +# 'ArcData' is valid from agent 1.36 onward; in earlier versions the +# arcdataservices endpoints fell under the 'Arc' category. $bypassCategoryEndpoints = @{ 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') 'ARM' = @('management.azure.com') @@ -748,7 +796,9 @@ $httpBypassedEndpoints = [System.Collections.ArrayList]::new() foreach ($cat in $proxyBypassCategories) { if ($bypassCategoryEndpoints.ContainsKey($cat)) { foreach ($bep in $bypassCategoryEndpoints[$cat]) { - [void]$httpBypassedEndpoints.Add($bep) + if ($httpBypassedEndpoints -notcontains $bep) { + [void]$httpBypassedEndpoints.Add($bep) + } } } } @@ -757,32 +807,33 @@ foreach ($ep in $httpProbeEndpoints) { $ep = $ep.Trim() if (-not $ep) { continue } - # Se o endpoint esta no bypass do azcmagent e usamos proxy, HTTP test via proxy daria falso positivo + # If the endpoint is in the azcmagent bypass and we use a proxy, an HTTP test via proxy would give a false positive if ($httpBypassedEndpoints -contains $ep) { Add-Result -Endpoint $ep -HTTP 'SKIP (bypass)' - Write-Log "HTTP SKIP $ep (azcmagent proxy.bypass cobre este endpoint — agente nao usa proxy)" Info -NoCount + Write-Log "HTTP SKIP $ep (azcmagent proxy.bypass covers this endpoint — agent does not use a proxy)" Info -NoCount continue } [void]$script:LogBuffer.Add('-' * 60) Add-Result -Endpoint $ep + $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $resp = Invoke-WebRequestSafe -Uri "https://$ep" -TimeoutSec 10 $sw.Stop() $elapsed = [math]::Round($sw.Elapsed.TotalSeconds, 2) - Write-Log "HTTP OK $ep -> $($resp.StatusCode) em ${elapsed}s" OK + Write-Log "HTTP OK $ep -> $($resp.StatusCode) in ${elapsed}s" OK $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } if ($existing4) { $existing4.HTTP = "OK ($($resp.StatusCode))" } } catch { - $sw.Stop() + if ($sw.IsRunning) { $sw.Stop() } $code = $null if ($_.Exception.Response) { try { $code = [int]$_.Exception.Response.StatusCode } catch { } } if ($code -in 400, 401, 403, 404) { - Write-Log "HTTP OK $ep -> $code (esperado sem auth/sem root handler)" OK + Write-Log "HTTP OK $ep -> $code (expected without auth/without root handler)" OK $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } if ($existing4) { $existing4.HTTP = "OK ($code)" } } @@ -807,10 +858,10 @@ $azcm = Get-AzcmagentPath if ($azcm) { $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud') if ($CheckIncludeAll) { - # Doc oficial: '--extensions' e '--include-all' sao ORTOGONAIS. - # --extensions all -> endpoints de TODAS as extensoes (SQL, etc.) - # --include-all -> casos de uso ESTENDIDOS (ex.: Windows Server PAYG) - # Combinamos os dois para cobertura total. + # Official doc: '--extensions' and '--include-all' are ORTHOGONAL. + # --extensions all -> endpoints for ALL extensions (SQL, etc.) + # --include-all -> EXTENDED use cases (e.g. Windows Server PAYG) + # We combine both for full coverage. $checkArgs += @('--extensions', 'all', '--include-all') } elseif ($IncludeSQL) { @@ -818,42 +869,42 @@ if ($azcm) { } if ($Mode -eq 'Private') { $checkArgs += '--enable-pls-check' } - Write-Log "Executando: azcmagent $($checkArgs -join ' ')" Info -NoCount - Save-LogBuffer # garante ordem: cabecalho antes do output do binario + Write-Log "Running: azcmagent $($checkArgs -join ' ')" Info -NoCount + Save-LogBuffer # ensure ordering: header before the binary output try { $out = & $azcm @checkArgs 2>&1 Add-Content -Path $LogFilePath -Value $out if ($LASTEXITCODE -eq 0) { - Write-Log 'azcmagent check concluido (exit 0).' OK + Write-Log 'azcmagent check completed (exit 0).' OK } else { - Write-Log "azcmagent check terminou com exit $LASTEXITCODE." Fail + Write-Log "azcmagent check finished with exit $LASTEXITCODE." Fail } } catch { - Write-Log "azcmagent check falhou: $($_.Exception.Message)" Fail + Write-Log "azcmagent check failed: $($_.Exception.Message)" Fail } } else { - Write-Log 'azcmagent.exe nao encontrado - pulando check.' Warn + Write-Log 'azcmagent.exe not found - skipping check.' Warn } # --------------------------------------------------------------------------- -# Resumo +# Summary # --------------------------------------------------------------------------- [void]$script:LogBuffer.Add('=' * 60) -Write-Log ("Resumo: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` +Write-Log ("Summary: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) Info -NoCount Write-Log "Script finished at $(Get-Date -Format o)" Info -NoCount Save-LogBuffer # --------------------------------------------------------------------------- -# Tabela de resultados (console + log) +# Results table (console + log) # --------------------------------------------------------------------------- $tableObjects = $script:Results | ForEach-Object { [pscustomobject]$_ } Write-Host '' -Write-Host '=================== SUMARIO ===================' -ForegroundColor Cyan +Write-Host '=================== SUMMARY ===================' -ForegroundColor Cyan $rowFormat = "{0,-5} {1,-55} {2,-26} {3,-8} {4,-5} {5,-5} {6,-12} {7,-9}" Write-Host ($rowFormat -f 'Group', 'Endpoint', 'IP', 'Type', 'DNS', 'TCP', 'HTTP', 'Latency') -ForegroundColor Cyan @@ -861,26 +912,35 @@ Write-Host ($rowFormat -f ('-' * 5), ('-' * 55), ('-' * 26), ('-' * 8), ('-' * 5 foreach ($r in $tableObjects) { $hasFail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') - $hasWarn = ($r.DNS -eq 'WARN') + $hasWarn = ($r.DNS -eq 'WARN') $color = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } Write-Host ($rowFormat -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) -ForegroundColor $color } Write-Host '' -Write-Host ("Totais: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` +Write-Host ("Totals: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) -ForegroundColor Cyan if ($script:EffectiveProxy) { - Write-Host "Proxy utilizado: $($script:EffectiveProxy)" -ForegroundColor DarkGray + Write-Host "Proxy used: $($script:EffectiveProxy)" -ForegroundColor DarkGray +} +else { + Write-Host 'Proxy used: Direct (no proxy)' -ForegroundColor DarkGray } -# Append tabela ao arquivo de log +# Append table to the log file $tableString = $tableObjects | Format-Table -AutoSize | Out-String Add-Content -Path $LogFilePath -Value '' -Add-Content -Path $LogFilePath -Value '=================== SUMARIO ===================' +Add-Content -Path $LogFilePath -Value '=================== SUMMARY ===================' Add-Content -Path $LogFilePath -Value $tableString.TrimEnd() -Add-Content -Path $LogFilePath -Value ("Totais: OK={0} Fail={1} Warn={2} Modo={3} Regiao={4}" -f ` +Add-Content -Path $LogFilePath -Value ("Totals: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) +if ($script:EffectiveProxy) { + Add-Content -Path $LogFilePath -Value "Proxy used: $($script:EffectiveProxy)" +} +else { + Add-Content -Path $LogFilePath -Value 'Proxy used: Direct (no proxy)' +} -Write-Host "`nLog completo: $LogFilePath" -ForegroundColor Cyan +Write-Host "`nFull log: $LogFilePath" -ForegroundColor Cyan exit ([int]($script:Stats.Fail -gt 0)) From 34e1f667d12115daf3c20cc1df7401b13f8d16d5 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:43:23 -0300 Subject: [PATCH 06/10] Revise documentation for Azure Arc Connectivity Check Enhance Azure Arc Connectivity Check script documentation with detailed validation processes, key improvements, and usage examples. --- .../arc_endpoint_check/_index.md | 302 ++++++++++++++---- 1 file changed, 239 insertions(+), 63 deletions(-) diff --git a/script_automation/arc_endpoint_check/_index.md b/script_automation/arc_endpoint_check/_index.md index 2bccc16c..184ddebb 100644 --- a/script_automation/arc_endpoint_check/_index.md +++ b/script_automation/arc_endpoint_check/_index.md @@ -4,90 +4,266 @@ title: "Azure Arc Connectivity Check" linkTitle: "Azure Arc Connectivity Check" weight: 1 description: > - Validate Azure Connected Machine agent connectivity and endpoints for public - or Private Link deployments, with automatic mode detection and proxy awareness. + Validate Azure Arc agent connectivity, TLS configuration, proxy bypass, and + extension endpoints across Public, Private Link, and Gateway deployments — + with full auto-detection and pre-onboarding support. --- ## Overview -This script helps identify connectivity issues with the Azure Connected Machine agent -and its required endpoints. It validates the endpoint list from the official Azure Arc -network requirements, performs **DNS resolution**, **TCP/443** reachability, and **HTTP** -probes, runs `azcmagent check`, and logs everything for review. - -Compared to earlier versions, it is fully **parameter-driven** (no manual editing of the -script is required) and adds: - -- **Automatic Public vs Private Link detection** (`azcmagent show` + DNS heuristic), with a - manual override (`-Mode`). -- **Proxy awareness** that mirrors the Connected Machine agent precedence - (`azcmagent proxy.url` > `HTTPS_PROXY`) and honors `proxy.bypass` categories - (AAD, ARM, Arc, AMA, ArcData). The Windows system-wide proxy (WinHTTP/WinINET) is - reported but never applied automatically, matching the agent's behavior. -- **Optional extension endpoint groups**: SQL Server enabled by Azure Arc, Azure Monitor - Agent (AMA), Microsoft Defender for Endpoint (MDE), and Windows Admin Center (WAC). -- **Dynamic endpoint allowlist**: in Public mode the script also queries the - `guestnotificationservice` allowlist for the region and validates those endpoints - (primary namespaces only). Failures here are treated as warnings and never affect the - exit code, since this list is auxiliary. -- **IPv4-first DNS resolution** (Private Link uses A records), avoiding false "public" - classification when public AAAA records coexist. -- A machine-readable **exit code** (`0` = all checks OK, `1` = at least one failure). +This script validates network connectivity for Azure Arc-enabled servers across all +three connectivity modes: **Public**, **Private Link**, and **Gateway**. It auto-detects +region, connectivity mode, proxy configuration, installed extensions, and regional +endpoints — run it with **zero parameters** on any machine where the agent is installed. + +For **pre-onboarding** (agent not yet installed), pass `-Region`, `-Mode`, and +`-CheckIncludeAll` to validate the network before deploying. + +### What it validates + +| Area | Details | +| ---- | ------- | +| **Core endpoints** | AAD, ARM, HIMDS, GuestConfig, GNS, packages, downloads | +| **Regional endpoints** | Discovered via `azcmagent check` or DNS-based abbreviation map (40+ regions) | +| **Extension endpoints** | SQL, AMA, MDE, WAC, Key Vault, Hybrid Worker, Change Tracking, Update Manager, Guest Attestation, Dependency Agent, Defender for SQL — auto-detected from installed extensions | +| **DNS + TCP/443** | Resolution, private vs public IP classification, latency measurement | +| **HTTP probes** | Layer-7 reachability through proxy for key endpoints | +| **TLS 1.2/1.3** | SCHANNEL registry, live handshake test, cipher suite validation (GCM), .NET StrongCrypto | +| **PKI/OCSP/CRL bypass** | Detects missing proxy bypass for certificate validation endpoints (Azure Firewall explicit proxy "non-proxy request on proxy port" scenario) | +| **Proxy configuration** | WinHTTP, `azcmagent proxy.url`, `HTTPS_PROXY`, `proxy.bypass` categories, upstream proxy (Gateway) | +| **Dynamic GNS allowlist** | Queries `guestnotificationservice.azure.com` for region-specific ServiceBus endpoints (Public mode) | +| **Arc Gateway** | Validates gateway URL, detects `proxy.bypass` misconfiguration in Gateway mode | + +### Key improvements over earlier versions + +- **Zero parameters needed** — auto-detects everything from `azcmagent show -j`, + `azcmagent check`, `azcmagent extension list`, WinHTTP, and environment variables. +- **Three connectivity modes** — Public, Private Link, and Gateway (with gateway URL + validation and upstream proxy display). +- **Pre-onboarding mode** — works without `azcmagent` installed; uses DNS-based regional + endpoint discovery with a built-in abbreviation map for 40+ Azure regions. +- **PKI bypass validation** — detects when WinHTTP proxy is configured but PKI/OCSP/CRL + endpoints are missing from the bypass list (root cause of Azure Firewall explicit proxy + TLS failures). +- **TLS validation** — SCHANNEL registry check, live TLS 1.2 handshake, cipher suite + verification, .NET StrongCrypto, and Server 2012 (non-R2) SQL Arc incompatibility + warning. +- **Extension auto-detection** — discovers 12 extension types from `azcmagent extension list` + and tests their specific endpoints. +- **Pipe-delimited output** — `azcmagent check` style tabular format with consolidated + `Result` column (Reachable / FAIL(DNS,TCP) / Warning). +- **Smart exit codes** — `0` = PASS or WARN-only, `1` = FAIL (CRITICAL/HIGH issues). + WARN-level issues (e.g., Gateway bypass, Discovery) do not cause exit 1. +- **Locale-independent** WinHTTP proxy detection with URL-based fallback for non-EN/PT + systems. ## Prerequisites -- **Windows PowerShell 5.1 or later** (Windows only — the script uses `netsh`, - `Resolve-DnsName`, and `azcmagent.exe`). -- Outbound network connectivity to the Azure Arc endpoints (directly or via proxy). -- *(Optional)* The **Azure Connected Machine agent** (`azcmagent.exe`). It is only needed - for the final `azcmagent check`; DNS/TCP/HTTP tests run without it. +- **Windows PowerShell 5.1 or later** (the script uses `netsh`, `Resolve-DnsName`, and + `azcmagent.exe`). +- Outbound network connectivity to Azure Arc endpoints (directly, via proxy, or via + Gateway). +- *(Optional)* The **Azure Connected Machine agent** (`azcmagent.exe`). Without it the + script runs in pre-onboarding mode — DNS/TCP/HTTP/TLS tests still execute, but + `azcmagent check` and extension auto-detection are skipped. - Run from an **elevated PowerShell** session for the most complete results. ## Getting Started -Download [ArcEndpointCheck.ps1](./ArcEndpointCheck.ps1) and run it on the server where the -Azure Arc agent is (or will be) installed. - -Unlike previous versions, **you no longer edit the script**. Everything is controlled by -parameters: +Download [arcendpointcheck.ps1](./arcendpointcheck.ps1) and run it on the server where +the Azure Arc agent is (or will be) installed. -| Parameter | Description | Default | -| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------ | -| `-Region` | Azure region (e.g., `eastus2`, `brazilsouth`). | `eastus2` | -| `-Mode` | `Auto`, `Public`, or `Private`. `Private` automatically adds `--enable-pls-check` to `azcmagent check`. | `Auto` | -| `-ProxyUrl` | Explicit HTTP/HTTPS proxy (e.g., `http://10.0.1.4:8443`). If omitted, auto-detects `azcmagent proxy.url` then `HTTPS_PROXY`. | *(auto-detect)* | -| `-LogFilePath` | Path to the log file. | `C:\temp\Arclogfile.txt` | -| `-IncludeSQL` | Adds Azure Arc-enabled SQL Server endpoints (`*.arcdataservices.com`, plus `graph.microsoft.com` for Microsoft Entra auth). | *(off)* | -| `-IncludeAMA` | Adds Azure Monitor Agent endpoints. | *(off)* | -| `-IncludeMDE` | Adds Microsoft Defender for Endpoint endpoints. | *(off)* | -| `-IncludeWAC` | Adds Windows Admin Center endpoints. | *(off)* | -| `-CheckIncludeAll` | Runs `azcmagent check` with `--extensions all --include-all` (all extensions + extended use cases such as Windows Server pay-as-you-go). | *(off)* | +**You never edit the script.** Everything is controlled by parameters: -> The public/private choice is handled automatically. In `Private` mode the script adds -> `--enable-pls-check` for you — there is no longer any parameter to remove manually. +| Parameter | Description | Default | +| --------- | ----------- | ------- | +| `-Region` | Azure region (e.g., `eastus2`, `brazilsouth`). Auto-detected from `azcmagent show` if omitted. | *(auto-detect or `eastus2`)* | +| `-Mode` | `Auto`, `Public`, `Private`, or `Gateway`. In `Auto`, the script checks `azcmagent show` for Private Link Scope or Gateway URL, then falls back to a DNS heuristic (`gbl.his.arc.azure.com` → private IP = Private). `Private` automatically adds `--enable-pls-check` to `azcmagent check`. | `Auto` | +| `-ProxyUrl` | Explicit HTTP/HTTPS proxy (e.g., `http://10.0.1.4:8443`). If omitted, auto-detects in order: `azcmagent proxy.url` → `HTTPS_PROXY` env var → WinHTTP (pre-onboarding only). | *(auto-detect)* | +| `-LogFilePath` | Path to the log file. Includes hostname automatically. | `C:\temp\ArcEndpointCheck_.txt` | +| `-SkipPKI` | Skips PKI/OCSP/CRL bypass validation and endpoint testing (not recommended). | *(off)* | +| `-SkipExtensions` | Skips all extension endpoint testing. Overrides `-CheckIncludeAll`. | *(off)* | +| `-CheckIncludeAll` | Tests ALL extension endpoints (even without agent detection). Also makes `azcmagent check` use `--extensions all --include-all`. Ideal for pre-onboarding validation. | *(off)* | ## Using the Script -Run the script on the target server, keeping in mind environmental factors such as -firewall rules, proxy configuration, region, and whether the connection is public or -private. Examples: +### Post-onboarding (agent installed) + +```powershell +# Full auto — zero parameters needed +.\arcendpointcheck.ps1 + +# Force a specific region +.\arcendpointcheck.ps1 -Region brazilsouth + +# Force Private Link mode with verbose logging +.\arcendpointcheck.ps1 -Mode Private -Verbose + +# Test all extension endpoints (including not yet installed) +.\arcendpointcheck.ps1 -CheckIncludeAll +``` + +### Pre-onboarding (agent not installed) + +```powershell +# Minimum: region + test all extensions +.\arcendpointcheck.ps1 -Region eastus2 -CheckIncludeAll + +# Full pre-onboarding with proxy + Private Link +.\arcendpointcheck.ps1 -Region eastus2 -Mode Private -ProxyUrl http://10.0.1.4:8443 -CheckIncludeAll + +# Public mode, specific region, skip PKI (firewall team will handle) +.\arcendpointcheck.ps1 -Region brazilsouth -Mode Public -SkipPKI -CheckIncludeAll +``` + +### Azure Firewall explicit proxy scenarios ```powershell -# Auto-detect Public/Private, default region (eastus2) -.\ArcEndpointCheck.ps1 +# Validate connectivity through explicit proxy (auto-detected from azcmagent/WinHTTP) +.\arcendpointcheck.ps1 + +# Override proxy URL if not yet configured in azcmagent +.\arcendpointcheck.ps1 -ProxyUrl http://10.0.1.4:8443 +``` -# Specific region + SQL and AMA endpoints -.\ArcEndpointCheck.ps1 -Region brazilsouth -IncludeSQL -IncludeAMA +## Output Format -# Force an explicit proxy for all HTTP tests -.\ArcEndpointCheck.ps1 -Region eastus2 -ProxyUrl http://10.0.1.4:8443 +The script produces pipe-delimited tables matching the `azcmagent check` style: -# Force Public mode (useful before the agent is installed) -.\ArcEndpointCheck.ps1 -Mode Public +### Header + +``` +========================================================================== + AZURE ARC ENDPOINT CHECK +========================================================================== + Host: SQLNODE1 + Time: 2026-07-03 17:38:51 + Agent: Installed | Connected | v1.65.03439.3010 + Region: eastus2 (auto-detected) + Mode: Private + Extensions: SQL, AMA, MDE, WAC, CT, UM, DSQL +``` + +### Proxy Configuration + +``` + Source | Proxy | Used By + -------------------+-------------------------------------+----------------------- + WinHTTP (OS) | http://10.0.1.4:8443 | SCHANNEL/OCSP/CRL + azcmagent | http://10.0.1.4:8443 | Arc Agent + HTTPS_PROXY | http://10.0.1.4:8443 | Extensions +``` -# Force Private Link validation (adds --enable-pls-check) with a custom log path -.\ArcEndpointCheck.ps1 -Mode Private -LogFilePath D:\logs\arc-pls.txt +### Results Table -# Full pre-onboarding validation of all extension endpoints -.\ArcEndpointCheck.ps1 -Mode Private -CheckIncludeAll -Verbose -IncludeSQL -IncludeAMA -IncludeMDE -IncludeWAC ``` + Group | Endpoint | IP | Type | Result | Latency + ------+----------------------------------------------------+------------------+------+-----------+-------- + Core | login.windows.net | 20.190.173.132 | PUB | Reachable | 24ms + Core | gbl.his.arc.azure.com | 10.1.0.4 | PRIV | Reachable | 305ms + PKI | oneocsp.microsoft.com | 204.79.197.203 | PUB | Reachable | 33ms + SQL | dataprocessingservice.eastus2.arcdataservices.com | 72.153.30.41 | PUB | Reachable | 130ms +``` + +**Result values:** + +| Result | Meaning | +| ------ | ------- | +| `Reachable` | DNS + TCP + HTTP all passed | +| `Reachable*` | DNS + TCP passed, HTTP skipped (proxy.bypass active) | +| `FAIL(DNS)` | DNS resolution failed | +| `FAIL(TCP)` | TCP/443 connection timed out | +| `FAIL(DNS,TCP)` | Both DNS and TCP failed | +| `FAIL(HTTP)` | HTTP probe failed (proxy/firewall blocking) | +| `Warning` | Mode mismatch (e.g., Private mode but endpoint resolves to public IP) | + +### Issues Table + +``` + # | Severity | Category | Message + ----+----------+--------------+--------------------------------------------------- + 1 | CRITICAL | PKI Bypass | 2 PKI endpoint(s) not in proxy bypass + | | | Fix: Add to GPO NO_PROXY: crl4.digicert.com +``` + +### Summary Line + +``` + STATUS: PASS | OK:74 Fail:0 Warn:0 Issues:0 | Private eastus2 +``` + +Tags appended when applicable: `[GW]` for Gateway mode, `[PRE-ONBOARDING]` when agent +is not installed. + +## Exit Codes + +| Code | Status | Meaning | +| ---- | ------ | ------- | +| `0` | PASS | All checks passed | +| `0` | WARN | Only WARN/MEDIUM severity issues (e.g., Gateway bypass, Discovery) | +| `1` | FAIL | At least one CRITICAL or HIGH severity issue, or a DNS/TCP test failure | + +## Auto-Detection Logic + +The script automatically detects the following without any parameters: + +| What | Source | Fallback | +| ---- | ------ | -------- | +| **Region** | `azcmagent show -j` → `.location` | Default `eastus2` (with warning) | +| **Mode** | `azcmagent show -j` → `.privateLinkScope` / `.gatewayUrl` / `.connectionType` | DNS heuristic: `gbl.his.arc.azure.com` → private IP = Private | +| **Proxy** | `azcmagent config get proxy.url` → `HTTPS_PROXY` env → WinHTTP (pre-onboarding) | None (direct) | +| **Extensions** | `azcmagent extension list` (12 types: SQL, AMA, MDE, WAC, KV, HRW, CT, GA, UM, CS, DA, DSQL) | `-CheckIncludeAll` tests all | +| **Regional endpoints** | `azcmagent check` output parsing | DNS probe with abbreviation map (40+ regions) | +| **Gateway URL** | `azcmagent show -j` → `.gatewayUrl` | Manual `-Mode Gateway` | +| **Agent status** | `azcmagent show -j` → `.status` / `.agentVersion` | N/A | + +## PKI/OCSP/CRL Bypass Validation + +When a WinHTTP proxy is detected, the script validates that all PKI endpoints are in +the proxy bypass list (WinHTTP bypass or `NO_PROXY` environment variable). This is +critical for **Azure Firewall explicit proxy** deployments, where Windows SCHANNEL +sends OCSP/CRL requests through WinHTTP — if these endpoints are not bypassed, the +firewall rejects them with *"Received a non-proxy request on a proxy port"*. + +**PKI endpoints validated:** + +| Endpoint | Purpose | +| -------- | ------- | +| `oneocsp.microsoft.com` | OCSP primary | +| `crl.microsoft.com` | CRL Microsoft root | +| `crl2.microsoft.com` | CRL Microsoft intermediate | +| `crl3.digicert.com` | CRL DigiCert | +| `crl4.digicert.com` | CRL DigiCert alt | +| `ocsp.digicert.com` | OCSP DigiCert | +| `ctldl.windowsupdate.com` | Certificate Trust List | +| `www.microsoft.com` | PKI AIA chain | +| `caissuers.microsoft.com` | CA Issuers (AIA) | +| `login.live.com` | Live ID cert validation | + +The script normalizes both WinHTTP wildcard format (`*.domain.com`) and `NO_PROXY` +format (`.domain.com`) for accurate bypass matching. + +## TLS Validation + +Azure Arc requires **TLS 1.2 or 1.3**. The script performs a multi-layer check: + +1. **SCHANNEL registry** — verifies TLS 1.2 Client is not disabled via + `DisabledByDefault` or `Enabled=0`. +2. **Live handshake** — attempts a real TLS 1.2 connection to + `login.microsoftonline.com` (through proxy if configured). +3. **Cipher suites** — verifies required GCM ciphers are present: + - TLS 1.2: `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`, + `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` + - TLS 1.3: `TLS_AES_256_GCM_SHA384`, `TLS_AES_128_GCM_SHA256` +4. **.NET StrongCrypto** — checks `SchUseStrongCrypto` registry (recommended for + PS 5.1). +5. **Server 2012 (non-R2)** — warns that SQL Arc (`*.arcdataservices.com`) is not + supported. + +## References + +- [Azure Arc network requirements (consolidated)](https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated) +- [Azure Arc Gateway](https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway) +- [Azure Firewall explicit proxy with Arc](https://learn.microsoft.com/azure/azure-arc/azure-firewall-explicit-proxy) +- [Troubleshoot Windows TLS configuration](https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues) +- [Private Link for Azure Arc](https://learn.microsoft.com/azure/azure-arc/servers/private-link-security) From c62462d52188133e1be834275beac9d09fc057a5 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:44:08 -0300 Subject: [PATCH 07/10] Update ArcEndpointCheck.ps1 --- .../arc_endpoint_check/ArcEndpointCheck.ps1 | 1874 +++++++++++------ 1 file changed, 1185 insertions(+), 689 deletions(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index f1f45970..7eb64cbe 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -2,100 +2,67 @@ <# .SYNOPSIS - Validates Azure Arc connectivity, DNS resolution, and endpoint reachability - (public or Azure Private Link). + Validates Azure Arc connectivity, DNS, TCP, HTTP, TLS, proxy, PKI bypass, + and extension endpoints for Arc-enabled servers. .DESCRIPTION - - Automatically detects whether the host uses Azure Arc public endpoints or an - Azure Arc Private Link Scope (PLS): - 1) 'azcmagent show -j' - if it reports a privateLinkScope => Private - 2) otherwise, resolves 'gbl.his.arc.azure.com' and classifies as Private - when the IP is RFC1918 (covers the "DNS-based" Private Link scenario) - - Tests DNS, TCP/443, and (for selected endpoints) HTTP. - - Runs 'azcmagent check' with the correct flag for the detected mode. - - Detects and displays the proxy configuration following the agent precedence - (azcmagent proxy.url > HTTPS_PROXY). The Windows system-wide proxy - (WinHTTP/WinINET) is shown for information only, because the agent ignores it. - - Supports environments with Azure Firewall Explicit Proxy. + Auto-detects EVERYTHING: region, connectivity mode (Public/Private/Gateway), + proxy configuration, installed extensions, and regional endpoints. + + Connectivity modes supported (per Microsoft docs): + - Public: Direct internet or via forward proxy + - Private: Azure Private Link Scope (PLS) + - Gateway: Azure Arc Gateway (reduces endpoints to ~7 FQDNs) + + Validates: + - Core Arc agent endpoints (HIMDS, GuestConfig, GNS, AAD, ARM) + - Regional endpoints discovered from 'azcmagent check' + - PKI/OCSP/CRL proxy bypass (detects "non-proxy request on proxy port") + - TLS version (1.2+ required per Microsoft docs) + - Extension endpoints based on installed extensions (auto-detected) + - Arc Gateway URL when gateway mode is active + + Run with ZERO parameters for full auto-detection: + PS> .\arcendpointcheck.ps1 .PARAMETER Region - Azure region (default: eastus2). + Azure region. Auto-detected from azcmagent if omitted. .PARAMETER Mode - Auto | Public | Private. Default: Auto. + Auto | Public | Private | Gateway. Default: Auto. .PARAMETER ProxyUrl - HTTP/HTTPS proxy URL (e.g., http://10.0.1.4:8443). If omitted, the script - auto-detects using the same precedence as the Azure Arc agent: 1) azcmagent - proxy.url (agent config - takes precedence); 2) the HTTPS_PROXY environment - variable. The Windows system-wide proxy (WinHTTP/WinINET) is only reported, - never applied automatically - mirroring the agent. - Ref: https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings + Override proxy URL. Auto-detected if omitted. .PARAMETER LogFilePath Log file path. Default: C:\temp\Arclogfile.txt. -.PARAMETER IncludeSQL - Includes Azure Arc-enabled SQL Server endpoints: data processing service and - telemetry (*.arcdataservices.com), san-af (legacy), and graph.microsoft.com - (Microsoft Entra authentication). Aligned with the official Arc SQL connectivity - test (DPS => 200, telemetry => 401). - -.PARAMETER IncludeAMA - Includes Azure Monitor Agent (AMA) endpoints. +.PARAMETER SkipPKI + Skips PKI/OCSP/CRL testing (not recommended). -.PARAMETER IncludeMDE - Includes Microsoft Defender for Endpoint endpoints. - -.PARAMETER IncludeWAC - Includes Windows Admin Center endpoints. +.PARAMETER SkipExtensions + Skips extension endpoint testing. .PARAMETER CheckIncludeAll - Makes 'azcmagent check' validate everything: adds '--extensions all' (endpoints - for all supported extensions) and '--include-all' (extended use cases, e.g., - Windows Server pay-as-you-go). Useful before onboarding. Replaces the - '--extensions sql' that -IncludeSQL would add. - -.EXAMPLE - PS> .\ArcEndpointCheck.ps1 - Auto-detects the mode (Public/Private) and uses the default region 'eastus2'. - -.EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Region brazilsouth -IncludeSQL -IncludeAMA - Runs against brazilsouth including SQL and AMA endpoints. - -.EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Region eastus2 -ProxyUrl http://10.0.1.4:8443 - Forces an explicit proxy for all HTTP tests. - -.EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Region westeurope -Mode Public - Forces Public mode on westeurope (useful to validate the internet endpoint - list when the host does not have the agent installed yet). + Makes 'azcmagent check' use '--extensions all --include-all'. .EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Region brazilsouth -Mode Private -LogFilePath D:\logs\arc-pls.txt - Forces Private Link validation and writes the log to a custom path. Adds the - '--enable-pls-check' flag to 'azcmagent check'. + PS> .\arcendpointcheck.ps1 + Full auto: detects region, mode, proxy, extensions. Zero parameters needed. .EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Region southcentralus -Verbose -IncludeSQL -IncludeAMA -IncludeMDE -IncludeWAC - Runs with detailed verbose output and all endpoint groups. - Common regions: eastus, eastus2, westus2, westus3, centralus, northeurope, - westeurope, uksouth, francecentral, switzerlandnorth, southeastasia, - japaneast, australiaeast, brazilsouth, southafricanorth, uaenorth. + PS> .\arcendpointcheck.ps1 -Region brazilsouth -Mode Private + Forces region and mode override. .EXAMPLE - PS> .\ArcEndpointCheck.ps1 -Mode Private -CheckIncludeAll - Runs 'azcmagent check' with '--extensions all --include-all' (endpoints for all - extensions + extended use cases) in Private mode. + PS> .\arcendpointcheck.ps1 -Region eastus2 -Mode Private -ProxyUrl http://10.0.1.4:8443 -CheckIncludeAll + Pre-onboarding: agent not installed. Specify region, mode, proxy, and test all extensions. .NOTES - Requires PowerShell 5.1+ on Windows (uses netsh, Resolve-DnsName, and azcmagent.exe). - azcmagent.exe is optional (only for the final check). - Exit code: 0 = all tests OK; 1 = at least one failure. - Endpoint lists aligned with the Connected Machine agent network requirements and - its extensions (AMA/SQL/MDE/WAC). + Requires PowerShell 5.1+ on Windows. + Ref: https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated + https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway + https://learn.microsoft.com/azure/azure-arc/azure-firewall-explicit-proxy .LINK https://azurearcjumpstart.com @@ -103,844 +70,1373 @@ [CmdletBinding()] param( - [string]$Region = 'eastus2', + [string]$Region = '', - [ValidateSet('Auto', 'Public', 'Private')] + [ValidateSet('Auto', 'Public', 'Private', 'Gateway')] [string]$Mode = 'Auto', [string]$ProxyUrl, - [string]$LogFilePath = 'C:\temp\Arclogfile.txt', - - [switch]$IncludeSQL, - [switch]$IncludeAMA, - [switch]$IncludeMDE, - [switch]$IncludeWAC, + [string]$LogFilePath = "C:\temp\ArcEndpointCheck_$($env:COMPUTERNAME).txt", + [switch]$SkipPKI, + [switch]$SkipExtensions, [switch]$CheckIncludeAll ) -# --------------------------------------------------------------------------- -# Setup -# --------------------------------------------------------------------------- +# ========================================================================= +# SETUP +# ========================================================================= $ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' # speeds up Invoke-WebRequest and Test-NetConnection +$ProgressPreference = 'SilentlyContinue' $logDir = Split-Path -Path $LogFilePath -Parent if ($logDir -and -not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } -Set-Content -Path $LogFilePath -Value "Script started at $(Get-Date -Format o)" -Force +Set-Content -Path $LogFilePath -Value "ArcEndpointCheck started at $(Get-Date -Format o)" -Force + +$script:Stats = [ordered]@{ OK = 0; Fail = 0; Warn = 0 } +$script:Log = [System.Collections.ArrayList]::new() +$script:Results = [System.Collections.ArrayList]::new() +$script:Issues = [System.Collections.ArrayList]::new() + +# ========================================================================= +# HELPERS +# ========================================================================= + +function Write-Banner { + param([string]$T) + $w = 74 + Write-Host '' + Write-Host ('=' * $w) -ForegroundColor DarkCyan + Write-Host " $T" -ForegroundColor Cyan + Write-Host ('=' * $w) -ForegroundColor DarkCyan +} -$script:Stats = [ordered]@{ OK = 0; Fail = 0; Warn = 0 } -$script:LogBuffer = [System.Collections.ArrayList]::new() -$script:Results = [System.Collections.ArrayList]::new() +function Write-Section { + param([string]$T) + Write-Host '' + Write-Host " --- $T ---" -ForegroundColor DarkGray +} -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- +function Write-Status { + param([string]$Label, [string]$Value, [string]$Color = 'White') + Write-Host (" {0,-22} {1}" -f "${Label}:", $Value) -ForegroundColor $Color +} function Test-IsValidProxyUri { - <# - .SYNOPSIS - Returns $true only when the input is a well-formed absolute http:// or - https:// URI. Rejects informational strings that azcmagent returns when - the proxy is not configured (e.g. "proxy.url has not been set"). - #> - param([string]$Candidate) - if (-not $Candidate) { return $false } - $parsed = $null + param([string]$C) + if (-not $C) { return $false } + $p = $null return ( - [System.Uri]::TryCreate($Candidate, [System.UriKind]::Absolute, [ref]$parsed) -and - $parsed.Scheme -in @('http', 'https') + [System.Uri]::TryCreate($C, [System.UriKind]::Absolute, [ref]$p) -and + $p.Scheme -in @('http', 'https') + ) +} + +function Log { + param( + [string]$Msg, + [ValidateSet('Info', 'OK', 'Fail', 'Warn')][string]$Lv = 'Info', + [switch]$NoCount ) + $line = "[$(Get-Date -Format HH:mm:ss)] [$($Lv.ToUpper().PadRight(4))] $Msg" + [void]$script:Log.Add($line) + Write-Verbose $line + if (-not $NoCount) { + if ($Lv -eq 'OK') { $script:Stats.OK++ } + if ($Lv -eq 'Fail') { $script:Stats.Fail++ } + if ($Lv -eq 'Warn') { $script:Stats.Warn++ } + } } function Add-Result { param( - [Parameter(Mandatory)] [string]$Endpoint, - [string]$Group = 'Core', - [string]$IP = '-', - [string]$Type = '-', - [string]$DNS = '-', - [string]$TCP = '-', - [string]$HTTP = '-', - [string]$Latency = '-' + [string]$Endpoint, [string]$Group = 'Core', [string]$IP = '-', + [string]$Type = '-', [string]$DNS = '-', [string]$TCP = '-', + [string]$HTTP = '-', [string]$Latency = '-' ) - # Check if endpoint already exists and update - $existing = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } - if ($existing) { - if ($IP -ne '-') { $existing.IP = $IP } - if ($Type -ne '-') { $existing.Type = $Type } - if ($DNS -ne '-') { $existing.DNS = $DNS } - if ($TCP -ne '-') { $existing.TCP = $TCP } - if ($HTTP -ne '-') { $existing.HTTP = $HTTP } - if ($Latency -ne '-') { $existing.Latency = $Latency } + $ex = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } + if ($ex) { + if ($IP -ne '-') { $ex.IP = $IP } + if ($Type -ne '-') { $ex.Type = $Type } + if ($DNS -ne '-') { $ex.DNS = $DNS } + if ($TCP -ne '-') { $ex.TCP = $TCP } + if ($HTTP -ne '-') { $ex.HTTP = $HTTP } + if ($Latency -ne '-') { $ex.Latency = $Latency } } else { [void]$script:Results.Add([ordered]@{ - Endpoint = $Endpoint - Group = $Group - IP = $IP - Type = $Type - DNS = $DNS - TCP = $TCP - HTTP = $HTTP - Latency = $Latency + Endpoint = $Endpoint; Group = $Group; IP = $IP; Type = $Type + DNS = $DNS; TCP = $TCP; HTTP = $HTTP; Latency = $Latency }) } } -function Write-Log { - param( - [Parameter(Mandatory)] [string]$Message, - [ValidateSet('Info', 'OK', 'Fail', 'Warn')] [string]$Level = 'Info', - [switch]$NoCount - ) - $color = @{ Info = 'Gray'; OK = 'Green'; Fail = 'Red'; Warn = 'Yellow' }[$Level] - $line = "[{0}] [{1,-4}] {2}" -f (Get-Date -Format HH:mm:ss), $Level.ToUpper(), $Message - Write-Host $line -ForegroundColor $color - [void]$script:LogBuffer.Add($line) - - if (-not $NoCount) { - if ($Level -eq 'OK') { $script:Stats.OK++ } - if ($Level -eq 'Fail') { $script:Stats.Fail++ } - if ($Level -eq 'Warn') { $script:Stats.Warn++ } - } +function Add-Issue { + param([string]$Sev, [string]$Cat, [string]$Msg, [string]$Fix = '') + [void]$script:Issues.Add([ordered]@{ + Severity = $Sev; Category = $Cat; Message = $Msg; Fix = $Fix + }) } -function Save-LogBuffer { - if ($script:LogBuffer.Count -gt 0) { - Add-Content -Path $LogFilePath -Value $script:LogBuffer - $script:LogBuffer.Clear() +function Save-Log { + if ($script:Log.Count -gt 0) { + Add-Content -Path $LogFilePath -Value $script:Log + $script:Log.Clear() } } function Test-TcpPort { - param( - [Parameter(Mandatory)] [string]$ComputerName, - [int]$Port = 443, - [int]$TimeoutMs = 5000 - ) - $client = [System.Net.Sockets.TcpClient]::new() + param([string]$H, [int]$P = 443, [int]$T = 5000) + $c = [System.Net.Sockets.TcpClient]::new() try { - $iar = $client.BeginConnect($ComputerName, $Port, $null, $null) - if ($iar.AsyncWaitHandle.WaitOne($TimeoutMs, $false) -and $client.Connected) { - $client.EndConnect($iar) | Out-Null + $r = $c.BeginConnect($H, $P, $null, $null) + if ($r.AsyncWaitHandle.WaitOne($T, $false) -and $c.Connected) { + $c.EndConnect($r) | Out-Null return $true } return $false } catch { return $false } - finally { $client.Close() } + finally { $c.Close() } } -function Invoke-WebRequestSafe { - param( - [Parameter(Mandatory)] [string]$Uri, - [int]$TimeoutSec = 10 - ) - $params = @{ - Uri = $Uri - Method = 'Get' - UseBasicParsing = $true - TimeoutSec = $TimeoutSec - ErrorAction = 'Stop' +function Invoke-HttpSafe { + param([string]$Uri, [int]$Timeout = 10, [string]$UseProxy = '') + $p = @{ + Uri = $Uri; Method = 'Get'; UseBasicParsing = $true + TimeoutSec = $Timeout; ErrorAction = 'Stop' } - if ($script:EffectiveProxy) { - $params['Proxy'] = $script:EffectiveProxy - $params['ProxyUseDefaultCredentials'] = $true + $px = if ($UseProxy) { $UseProxy } else { $script:EffectiveProxy } + if ($px) { + $p['Proxy'] = $px + $p['ProxyUseDefaultCredentials'] = $true } elseif ($PSVersionTable.PSVersion.Major -ge 6) { - # PS 6+: mirror the agent, which IGNORES the system-wide proxy. On PS 5.1 the - # same effect is achieved by neutralizing .NET's DefaultWebProxy during setup. - $params['NoProxy'] = $true + $p['NoProxy'] = $true } - return Invoke-WebRequest @params + Invoke-WebRequest @p +} + +function Get-AzcmagentPath { + $c = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' + if (Test-Path $c) { return $c } + return $null } -# --------------------------------------------------------------------------- -# Proxy detection and display -# --------------------------------------------------------------------------- +function Test-IsPrivateIp { + param([string]$Ip) + if (-not $Ip) { return $false } + try { $b = ([System.Net.IPAddress]::Parse($Ip)).GetAddressBytes() } + catch { return $false } + return ($b[0] -eq 10) -or + ($b[0] -eq 192 -and $b[1] -eq 168) -or + ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) -or + ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) +} + +# ========================================================================= +# 1. AGENT DETECTION (region, mode, gateway, extensions) +# ========================================================================= + +Write-Banner 'AZURE ARC ENDPOINT CHECK' +Write-Status 'Host' $env:COMPUTERNAME +Write-Status 'Time' (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + $script:EffectiveProxy = $null +$script:WinHttpProxy = $null +$script:WinHttpBypass = $null +$script:AgentJson = $null +$script:GatewayUrl = $null +$script:InstalledExts = @() + +$azcm = Get-AzcmagentPath +$script:PreOnboarding = (-not $azcm) +if ($azcm) { + try { $script:AgentJson = & $azcm show -j 2>$null | ConvertFrom-Json } catch { } +} -function Get-ProxyDiagnostics { - Write-Log '=== PROXY DIAGNOSTICS ===' Info -NoCount - [void]$script:LogBuffer.Add('') - - # Effective proxy precedence (used by this script's HTTP tests) — mirrors the - # behavior of the Azure Connected Machine agent on Windows: - # 1) -ProxyUrl (explicit operator override) - # 2) azcmagent proxy.url (agent config — TAKES PRECEDENCE over env vars) - # 3) HTTPS_PROXY (env) (system-wide) - # The agent IGNORES the Windows system-wide proxy (WinHTTP/WinINET); that is why - # the WinHTTP value below is only REPORTED, never applied automatically. - # Ref: https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings - - # 1) -ProxyUrl parameter (validated as an absolute http/https URI) - if ($ProxyUrl) { - if (Test-IsValidProxyUri -Candidate $ProxyUrl) { - Write-Log "Proxy from parameter: $ProxyUrl" Info -NoCount - $script:EffectiveProxy = $ProxyUrl +# --- Pre-onboarding warning --- +if ($script:PreOnboarding) { + Write-Host '' + Write-Host ' ** PRE-ONBOARDING MODE **' -ForegroundColor Yellow + Write-Host ' azcmagent not installed. Region, mode, and extensions' -ForegroundColor Yellow + Write-Host ' cannot be auto-detected. Use parameters to override:' -ForegroundColor Yellow + Write-Host ' -Region -Mode ' -ForegroundColor DarkYellow + Write-Host ' -CheckIncludeAll (tests ALL extension endpoints)' -ForegroundColor DarkYellow + Write-Host ' -ProxyUrl (if proxy is not yet in WinHTTP/env)' -ForegroundColor DarkYellow + Write-Host '' +} + +if ($azcm -and $script:AgentJson) { + $agSt = if ($script:AgentJson.PSObject.Properties['status']) { $script:AgentJson.status } else { $null } + $agVer = if ($script:AgentJson.PSObject.Properties['agentVersion']) { $script:AgentJson.agentVersion } else { $null } + $agParts = @('Installed') + if ($agSt) { $agParts += $agSt } + if ($agVer) { $agParts += "v$agVer" } + $agColor = if ($agSt -eq 'Connected') { 'Green' } elseif ($agSt -eq 'Disconnected') { 'Red' } else { 'Yellow' } + Write-Status 'Agent' ($agParts -join ' | ') $agColor +} +elseif ($azcm) { + Write-Status 'Agent' 'Installed (could not read status)' Yellow +} +else { + Write-Status 'Agent' 'NOT INSTALLED (pre-onboarding)' Yellow +} + +# --- Region --- +if (-not $Region) { + if ($script:AgentJson -and $script:AgentJson.location) { + $Region = $script:AgentJson.location + Write-Status 'Region' "$Region (auto-detected)" Green + } + else { + $Region = 'eastus2' + Write-Status 'Region' "$Region (default - use -Region to override)" Yellow + } +} +else { + Write-Status 'Region' "$Region (specified)" White +} + +# --- Mode --- +if ($Mode -eq 'Auto') { + if ($script:AgentJson) { + # Check for Gateway mode + $gwUrl = if ($script:AgentJson.PSObject.Properties['gatewayUrl']) { $script:AgentJson.gatewayUrl } + elseif ($script:AgentJson.PSObject.Properties['gatewayurl']) { $script:AgentJson.gatewayurl } + else { $null } + $connType = if ($script:AgentJson.PSObject.Properties['connectionType']) { $script:AgentJson.connectionType } + elseif ($script:AgentJson.PSObject.Properties['connectiontype']) { $script:AgentJson.connectiontype } + else { $null } + $plsVal = if ($script:AgentJson.PSObject.Properties['privateLinkScope']) { $script:AgentJson.privateLinkScope } + elseif ($script:AgentJson.PSObject.Properties['privatelinkscope']) { $script:AgentJson.privatelinkscope } + else { $null } + + if ($gwUrl -or $connType -eq 'gateway') { + $Mode = 'Gateway' + $script:GatewayUrl = $gwUrl + } + elseif ($plsVal) { + $Mode = 'Private' } else { - Write-Log "Proxy from parameter INVALID (expected http:// or https://): $ProxyUrl" Warn + # DNS heuristic fallback + try { + $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop + $ip = ($dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1).IPAddress + $Mode = if (Test-IsPrivateIp -Ip $ip) { 'Private' } else { 'Public' } + } + catch { $Mode = 'Public' } + } + } + else { + # No agent — use DNS heuristic to detect Private Link + try { + $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop + $ip = ($dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1).IPAddress + $Mode = if (Test-IsPrivateIp -Ip $ip) { 'Private' } else { 'Public' } } + catch { $Mode = 'Public' } } +} + +$modeColor = switch ($Mode) { + 'Private' { 'Magenta' } + 'Gateway' { 'DarkYellow' } + default { 'Green' } +} +Write-Status 'Mode' $Mode $modeColor + +if ($script:GatewayUrl) { + Write-Status 'Gateway' $script:GatewayUrl DarkYellow +} - # WinHTTP (informational only — the agent ignores the system-wide proxy) - # Bilingual regex: EN "Proxy Server(s)" / pt-BR "Servidor(es) Proxy" +# --- Installed Extensions (auto-detect) --- +if (-not $SkipExtensions -and $azcm) { try { - $winhttp = netsh winhttp show proxy 2>$null - $winhttpText = ($winhttp | Out-String).Trim() - if ($winhttpText -match 'Proxy Server\(s\)\s*:\s*(.+)|Servidor\(es\) Proxy\s*:\s*(.+)') { - $winhttpProxy = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() - Write-Log "WinHTTP Proxy: $winhttpProxy (informational — the agent ignores the system-wide proxy)" Info -NoCount + $extOut = & $azcm extension list 2>$null + if ($extOut) { + $extLines = $extOut | Out-String + if ($extLines -match 'WindowsAgent\.SqlServer|LinuxAgent\.SqlServer|SqlServer') { $script:InstalledExts += 'SQL' } + if ($extLines -match 'AzureMonitor|AMA') { $script:InstalledExts += 'AMA' } + if ($extLines -match 'MDE|DefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } + if ($extLines -match 'AdminCenter') { $script:InstalledExts += 'WAC' } + if ($extLines -match 'KeyVault') { $script:InstalledExts += 'KV' } + if ($extLines -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } + if ($extLines -match 'ChangeTracking') { $script:InstalledExts += 'CT' } + if ($extLines -match 'GuestAttestation|WindowsAttestation|LinuxAttestation') { $script:InstalledExts += 'GA' } + if ($extLines -match 'WindowsPatchExtension|LinuxPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } + if ($extLines -match 'CustomScript') { $script:InstalledExts += 'CS' } + if ($extLines -match 'DependencyAgent') { $script:InstalledExts += 'DA' } + if ($extLines -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } } - else { - Write-Log 'WinHTTP Proxy: Direct (no proxy)' Info -NoCount + } + catch { } +} + +if ($script:InstalledExts.Count -gt 0) { + Write-Status 'Extensions' ($script:InstalledExts -join ', ') White +} +else { + if ($script:PreOnboarding) { + if ($CheckIncludeAll) { + Write-Status 'Extensions' '(all - pre-onboarding with -CheckIncludeAll)' DarkYellow } - if ($winhttpText -match 'Bypass List\s*:\s*(.+)|Lista de bypass\s*:\s*(.+)') { - $bypassVal = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() - Write-Log "WinHTTP Bypass: $bypassVal" Info -NoCount + else { + Write-Status 'Extensions' '(none - use -CheckIncludeAll to test all)' Yellow } } - catch { - Write-Log "WinHTTP: unable to query ($($_.Exception.Message))" Warn + else { + Write-Status 'Extensions' '(none detected)' DarkGray } +} - # 2) azcmagent config (proxy.url TAKES PRECEDENCE over HTTPS_PROXY) - # IMPORTANT: azcmagent returns informational phrases when the value is not - # configured (e.g. "proxy.url has not been set"). We validate with - # Test-IsValidProxyUri to accept ONLY real http/https URIs. - $azcm = Get-AzcmagentPath - if ($azcm) { - try { - $rawProxyUrl = & $azcm config get proxy.url 2>$null - $rawProxyUrlTrimmed = if ($rawProxyUrl) { ($rawProxyUrl | Out-String).Trim() } else { '' } +# ========================================================================= +# 2. PROXY DETECTION +# ========================================================================= - if (Test-IsValidProxyUri -Candidate $rawProxyUrlTrimmed) { - Write-Log "azcmagent proxy.url: $rawProxyUrlTrimmed" Info -NoCount - if (-not $script:EffectiveProxy) { - $script:EffectiveProxy = $rawProxyUrlTrimmed - } - } - else { - # Informational message (e.g. "proxy.url has not been set") - if ($rawProxyUrlTrimmed) { - Write-Log "azcmagent proxy.url: $rawProxyUrlTrimmed" Info -NoCount - } - else { - Write-Log 'azcmagent proxy.url: (not configured)' Info -NoCount - } - } +Write-Section 'Proxy Configuration' - $bypass = & $azcm config get proxy.bypass 2>$null - $bypassTrimmed = if ($bypass) { ($bypass | Out-String).Trim() } else { '' } - if ($bypassTrimmed) { - Write-Log "azcmagent proxy.bypass: $bypassTrimmed" Info -NoCount - } - } - catch { - Write-Log "azcmagent config: failed to query ($($_.Exception.Message))" Warn - } - } - - # 3) Environment variables (checks Machine -> Process -> User) - $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') - if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Process') } - if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'User') } - $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Machine') - if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process') } - if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'User') } - if ($envProxy) { - Write-Log "Env HTTPS_PROXY: $envProxy" Info -NoCount - if (-not $script:EffectiveProxy) { - if (Test-IsValidProxyUri -Candidate $envProxy) { - $script:EffectiveProxy = $envProxy - } - else { - Write-Log "Env HTTPS_PROXY INVALID (expected http:// or https://): $envProxy" Warn - } - } +# --- WinHTTP --- +try { + $wh = netsh winhttp show proxy 2>$null | Out-String + if ($wh -match 'Proxy Server\(s\)\s*:\s*(.+)|Servidor\(es\) Proxy\s*:\s*(.+)') { + $script:WinHttpProxy = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() } - else { - Write-Log 'Env HTTPS_PROXY: (not set)' Info -NoCount + # Generic URL fallback for unrecognized locales (FR, DE, ES, JP, etc.) + if (-not $script:WinHttpProxy -and $wh -notmatch 'Direct|direct|Direto|direto|Direkt' -and $wh -match '(https?://[^\s;]+)') { + $script:WinHttpProxy = $Matches[1].Trim() } - if ($envNoProxy) { - Write-Log "Env NO_PROXY: $envNoProxy" Info -NoCount + if ($wh -match 'Bypass List\s*:\s*(.+)|Lista de bypass\s*:\s*(.+)') { + $script:WinHttpBypass = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() } +} +catch { } - if ($script:EffectiveProxy) { - Write-Log "Effective proxy for HTTP tests: $($script:EffectiveProxy)" Info -NoCount - } - else { - Write-Log 'Effective proxy: Direct (no proxy — HTTP tests go direct, like the agent)' Info -NoCount +# --- Agent proxy --- +$agentProxy = $null +$agentBypass = $null +if ($azcm) { + try { + $raw = & $azcm config get proxy.url 2>$null | Out-String + $raw = $raw.Trim() + if (Test-IsValidProxyUri $raw) { $agentProxy = $raw } + $agentBypass = (& $azcm config get proxy.bypass 2>$null | Out-String).Trim() } + catch { } +} - [void]$script:LogBuffer.Add('') +# --- Env vars --- +$envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') +if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Process') } +$envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Machine') +if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process') } + +# --- Effective proxy (precedence: -ProxyUrl > azcmagent > HTTPS_PROXY > WinHTTP) --- +if ($ProxyUrl -and (Test-IsValidProxyUri $ProxyUrl)) { + $script:EffectiveProxy = $ProxyUrl +} +elseif ($agentProxy) { + $script:EffectiveProxy = $agentProxy +} +elseif ($envProxy -and (Test-IsValidProxyUri $envProxy)) { + $script:EffectiveProxy = $envProxy +} +elseif ($script:PreOnboarding -and $script:WinHttpProxy -and (Test-IsValidProxyUri "http://$($script:WinHttpProxy)")) { + # Pre-onboarding: no agent proxy, fallback to WinHTTP if configured + $whUri = if ($script:WinHttpProxy -match '^https?://') { $script:WinHttpProxy } else { "http://$($script:WinHttpProxy)" } + if (Test-IsValidProxyUri $whUri) { $script:EffectiveProxy = $whUri } } -# --------------------------------------------------------------------------- -# Automatic Public vs Private detection -# --------------------------------------------------------------------------- -function Get-AzcmagentPath { - $candidate = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' - if (Test-Path $candidate) { return $candidate } - return $null +# --- Upstream proxy (Gateway mode) --- +$upstreamProxy = $null +if ($script:AgentJson) { + $upstreamProxy = if ($script:AgentJson.PSObject.Properties['upstreamProxy']) { $script:AgentJson.upstreamProxy } + elseif ($script:AgentJson.PSObject.Properties['upstreamproxy']) { $script:AgentJson.upstreamproxy } + else { $null } } -function Test-IsPrivateIp { - param([string]$Ip) - if (-not $Ip) { return $false } - try { - $bytes = ([System.Net.IPAddress]::Parse($Ip)).GetAddressBytes() - } - catch { return $false } +# --- Display (pipe-delimited like azcmagent check) --- +$pFmt = " {0,-18} | {1,-35} | {2,-22}" +Write-Host ($pFmt -f 'Source', 'Proxy', 'Used By') -ForegroundColor Cyan +Write-Host (" {0,-18}-+-{1,-35}-+-{2,-22}" -f ('-' * 18), ('-' * 35), ('-' * 22)) -ForegroundColor DarkGray + +$agentProxyLabel = if ($script:PreOnboarding) { 'N/A (not installed)' } elseif ($agentProxy) { $agentProxy } else { '(not set)' } +$rows = @( + , @('WinHTTP (OS)', $(if ($script:WinHttpProxy) { $script:WinHttpProxy } else { 'Direct' }), 'SCHANNEL/OCSP/CRL') + , @('azcmagent', $agentProxyLabel, 'Arc Agent') + , @('HTTPS_PROXY', $(if ($envProxy) { $envProxy } else { '(not set)' }), 'Extensions') +) +if ($upstreamProxy) { + $rows += , @('Upstream Proxy', $upstreamProxy, 'Gateway chain') +} +foreach ($r in $rows) { + $c = if ($r[1] -match 'not set|Direct|N/A') { 'DarkGray' } else { 'White' } + Write-Host ($pFmt -f $r[0], $r[1], $r[2]) -ForegroundColor $c +} +if ($script:EffectiveProxy) { + Write-Host '' + Write-Status 'Effective proxy' $script:EffectiveProxy Green +} - # RFC1918 + 100.64/10 (CGNAT, common in corporate networks) - return ($bytes[0] -eq 10) -or - ($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or - ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or - ($bytes[0] -eq 100 -and $bytes[1] -ge 64 -and $bytes[1] -le 127) +# --- Gateway + proxy.bypass warning --- +if ($Mode -eq 'Gateway' -and $agentBypass) { + Add-Issue -Sev 'WARN' -Cat 'Gateway' ` + -Msg 'proxy.bypass is configured but NOT supported in Gateway mode' ` + -Fix 'Run: azcmagent config clear proxy.bypass' } -function Resolve-ArcMode { - Write-Log 'Detecting Arc mode (Public/Private)...' Info -NoCount +# Neutralize .NET DefaultWebProxy for PS 5.1 when no proxy +if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { + try { [System.Net.WebRequest]::DefaultWebProxy = $null } catch { } +} - # 1) Via azcmagent show -j - $azcm = Get-AzcmagentPath - if ($azcm) { - try { - $json = & $azcm show -j 2>$null | ConvertFrom-Json - $pls = $json.privateLinkScope - if ($pls) { - Write-Log "azcmagent reports privateLinkScope: $pls" Info -NoCount - return 'Private' - } - else { - # Do NOT conclude Public here: fall through to the DNS heuristic below. - # Private Link can be "DNS-based" (Private DNS Zones) without the agent - # exposing the PLS locally in 'azcmagent show -j'. - Write-Log 'azcmagent does not report privateLinkScope; confirming via DNS...' Info -NoCount - } - } - catch { - Write-Log "Failed to query azcmagent show -j: $($_.Exception.Message). Falling back to DNS." Warn - } +Log "Region=$Region Mode=$Mode Proxy=$($script:EffectiveProxy) Gateway=$($script:GatewayUrl)" Info -NoCount + +# ========================================================================= +# 3. TLS VERSION CHECK +# ========================================================================= + +Write-Section 'TLS Validation' +# Azure Arc requires TLS 1.2 or 1.3 ONLY. +# Required cipher suites: +# TLS 1.3: TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256 +# TLS 1.2: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +# SQL Arc endpoints (*.arcdataservices.com) require TLS 1.2/1.3 — Server 2012 (non-R2) NOT supported. +# Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues + +$tlsOk = $false +try { + $osVer = [System.Environment]::OSVersion.Version + $osBuild = $osVer.Build + $osCaption = (Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption + if (-not $osCaption) { $osCaption = "Windows $($osVer.Major).$($osVer.Minor) Build $osBuild" } + Write-Status 'OS' $osCaption DarkGray + + # --- 1. SCHANNEL Registry Check --- + $schBase = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols' + $tls12Disabled = $false + $tls12Path = "$schBase\TLS 1.2\Client" + if (Test-Path $tls12Path) { + $enVal = (Get-ItemProperty -Path $tls12Path -Name 'Enabled' -EA SilentlyContinue).Enabled + $dbVal = (Get-ItemProperty -Path $tls12Path -Name 'DisabledByDefault' -EA SilentlyContinue).DisabledByDefault + if ($enVal -eq 0) { $tls12Disabled = $true } + if ($dbVal -eq 1 -and $enVal -ne 1) { $tls12Disabled = $true } } - else { - Write-Log 'azcmagent.exe not found. Using DNS fallback.' Warn + + # TLS 1.3 support (Server 2022+ / Build 20348+) + $has13 = $false + $tls13Path = "$schBase\TLS 1.3\Client" + if (Test-Path $tls13Path) { + $en13 = (Get-ItemProperty -Path $tls13Path -Name 'Enabled' -EA SilentlyContinue).Enabled + if ($en13 -ne 0) { $has13 = $true } } + if ($osVer.Major -ge 10 -and $osBuild -ge 20348) { $has13 = $true } + + # OS era check + $isServer2012NonR2 = ($osVer.Major -eq 6 -and $osVer.Minor -eq 2) # 6.2 = Server 2012 / Win8 + $isModernOS = ($osVer.Major -gt 6) -or ($osVer.Major -eq 6 -and $osVer.Minor -ge 3) # 6.3+ = 2012R2+ - # 2) Fallback: resolve gbl.his.arc.azure.com + # --- 2. Real TLS 1.2 Handshake Test --- + $tlsHandshakeOk = $false + $negotiatedProto = '' try { - $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop - $ip = ($dns | Where-Object IPAddress | Select-Object -First 1).IPAddress - if (Test-IsPrivateIp -Ip $ip) { - Write-Log "gbl.his.arc.azure.com resolves to a private IP ($ip) -> Private Link" Info -NoCount - return 'Private' + # Force .NET to use TLS 1.2 for this test + $savedProto = [System.Net.ServicePointManager]::SecurityProtocol + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + $testReq = [System.Net.HttpWebRequest]::Create('https://login.microsoftonline.com') + $testReq.Timeout = 10000 + $testReq.Method = 'HEAD' + if ($script:EffectiveProxy) { + $testReq.Proxy = [System.Net.WebProxy]::new($script:EffectiveProxy) + $testReq.Proxy.UseDefaultCredentials = $true + } elseif ($PSVersionTable.PSVersion.Major -lt 6) { + $testReq.Proxy = $null } - else { - Write-Log "gbl.his.arc.azure.com resolves to a public IP ($ip) -> Public" Info -NoCount - return 'Public' + $testResp = $testReq.GetResponse() + $testResp.Close() + $tlsHandshakeOk = $true + $negotiatedProto = 'TLS 1.2' + [System.Net.ServicePointManager]::SecurityProtocol = $savedProto + } + catch { + try { [System.Net.ServicePointManager]::SecurityProtocol = $savedProto } catch { } + # If TLS 1.2 fails, the OS may not support it + } + + # --- 3. Cipher Suite Check --- + $requiredCiphers12 = @( + 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384' + 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256' + ) + $requiredCiphers13 = @( + 'TLS_AES_256_GCM_SHA384' + 'TLS_AES_128_GCM_SHA256' + ) + $cipherOk = $true + $missingCiphers = @() + try { + $sysCiphers = (Get-TlsCipherSuite -ErrorAction SilentlyContinue).Name + if ($sysCiphers) { + foreach ($rc in $requiredCiphers12) { + if ($sysCiphers -notcontains $rc) { $missingCiphers += $rc; $cipherOk = $false } + } } + # Get-TlsCipherSuite may not exist on older OS (Server 2012/2012R2) } catch { - Write-Log 'Could not resolve gbl.his.arc.azure.com - assuming Public.' Warn - return 'Public' + # Get-TlsCipherSuite not available — skip cipher check (older OS) + $cipherOk = $true } + + # --- 4. .NET Strong Crypto --- + $strongCrypto = $false + $regPath64 = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' + $regPath32 = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' + foreach ($rp in @($regPath64, $regPath32)) { + if (Test-Path $rp) { + $sc = (Get-ItemProperty -Path $rp -Name 'SchUseStrongCrypto' -EA SilentlyContinue).SchUseStrongCrypto + if ($sc -eq 1) { $strongCrypto = $true } + } + } + + # --- Display Results --- + if ($tls12Disabled) { + Write-Status 'SCHANNEL TLS 1.2' 'DISABLED in registry' Red + Add-Issue -Sev 'CRITICAL' -Cat 'TLS' ` + -Msg 'TLS 1.2 is disabled in SCHANNEL registry. Azure Arc requires TLS 1.2+.' ` + -Fix 'https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' + } + elseif ($tlsHandshakeOk) { + $tlsLabel = if ($has13) { 'TLS 1.2 + 1.3' } else { 'TLS 1.2' } + Write-Status 'TLS Handshake' "$tlsLabel verified (live test passed)" Green + $tlsOk = $true + } + elseif ($isModernOS) { + Write-Status 'TLS SCHANNEL' 'TLS 1.2 enabled (OS default, handshake test failed)' Yellow + $tlsOk = $true + } + else { + Write-Status 'TLS' 'Could not verify TLS 1.2 - check SCHANNEL config' Yellow + Add-Issue -Sev 'HIGH' -Cat 'TLS' ` + -Msg 'Cannot verify TLS 1.2 support. Azure Arc requires TLS 1.2+.' ` + -Fix 'https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' + } + + # Cipher suites + if (-not $cipherOk -and $missingCiphers.Count -gt 0) { + Write-Status 'Cipher Suites' "MISSING: $($missingCiphers -join ', ')" Red + Add-Issue -Sev 'HIGH' -Cat 'TLS Ciphers' ` + -Msg "Required cipher suites missing: $($missingCiphers -join ', ')" ` + -Fix 'Enable GCM cipher suites via Group Policy or PowerShell Enable-TlsCipherSuite' + } + elseif ($missingCiphers.Count -eq 0 -and $cipherOk) { + Write-Status 'Cipher Suites' 'Required GCM suites present' Green + } + + # .NET StrongCrypto + if ($strongCrypto) { + Write-Status '.NET StrongCrypto' 'Enabled' Green + } + else { + Write-Status '.NET StrongCrypto' 'NOT set (recommended for PS 5.1 / .NET apps)' Yellow + } + + # Server 2012 (non-R2) + SQL Arc warning + if ($isServer2012NonR2 -and ($script:InstalledExts -contains 'SQL' -or $CheckIncludeAll)) { + Write-Status 'SQL Arc TLS' 'Server 2012 (non-R2) NOT supported for SQL Arc telemetry' Red + Add-Issue -Sev 'HIGH' -Cat 'SQL TLS' ` + -Msg 'Windows Server 2012 (non-R2) does not support TLS 1.2 for *.arcdataservices.com endpoints.' ` + -Fix 'Upgrade to Server 2012 R2+ for SQL Server enabled by Azure Arc.' + } +} +catch { + Write-Status 'TLS' "Check error: $($_.Exception.Message)" Yellow + $tlsOk = $true } +Log "TLS check: OK=$tlsOk handshake=$tlsHandshakeOk ciphers=$cipherOk strongCrypto=$strongCrypto" $(if ($tlsOk) { 'OK' } else { 'Fail' }) + +# ========================================================================= +# 4. PKI/OCSP/CRL BYPASS VALIDATION +# ========================================================================= + +$pkiEndpoints = @( + 'oneocsp.microsoft.com' # OCSP primary + 'crl.microsoft.com' # CRL Microsoft root + 'crl2.microsoft.com' # CRL Microsoft intermediate + 'crl3.digicert.com' # CRL DigiCert + 'crl4.digicert.com' # CRL DigiCert alt + 'ocsp.digicert.com' # OCSP DigiCert + 'ctldl.windowsupdate.com' # Certificate Trust List + 'www.microsoft.com' # PKI AIA chain + 'caissuers.microsoft.com' # CA Issuers (AIA) + 'login.live.com' # Live ID cert validation +) -# --------------------------------------------------------------------------- -# Mode and proxy detection -# --------------------------------------------------------------------------- -Get-ProxyDiagnostics +$pkiWildcardCovers = @{ + '.microsoft.com' = @('oneocsp.microsoft.com', 'crl.microsoft.com', 'crl2.microsoft.com', + 'www.microsoft.com', 'caissuers.microsoft.com') + '.digicert.com' = @('crl3.digicert.com', 'crl4.digicert.com', 'ocsp.digicert.com') + '.live.com' = @('login.live.com') + '.ocsp.microsoft.com' = @('oneocsp.microsoft.com') + '.ocsp.digicert.com' = @('ocsp.digicert.com') +} -# Alignment with the agent: the Azure Connected Machine agent IGNORES the Windows -# system-wide proxy (WinINET/WinHTTP). If no effective proxy was detected, we -# neutralize .NET's DefaultWebProxy (PS 5.1) so that the HTTP tests also go direct. -# On PS 6+ this is done via -NoProxy. -if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { - try { [System.Net.WebRequest]::DefaultWebProxy = $null } catch { } +function Test-PkiBypassCoverage { + if (-not $script:WinHttpProxy) { return @() } + + $byList = @() + if ($script:WinHttpBypass) { + # WinHTTP uses *.domain.com format; normalize to .domain.com for matching + $byList += $script:WinHttpBypass -split ';' | + ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } | + ForEach-Object { if ($_ -match '^\*\.([a-z])') { $_.Substring(1) } else { $_ } } + } + if ($envNoProxy) { + $byList += $envNoProxy -split ',' | + ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } + } + if ($byList.Count -eq 0) { return $pkiEndpoints } + + $covered = [System.Collections.ArrayList]::new() + foreach ($wc in $pkiWildcardCovers.Keys) { + if ($byList -contains $wc.ToLower()) { + foreach ($ep in $pkiWildcardCovers[$wc]) { + if ($covered -notcontains $ep.ToLower()) { [void]$covered.Add($ep.ToLower()) } + } + } + } + + $uncovered = @() + foreach ($ep in $pkiEndpoints) { + $lo = $ep.ToLower() + if ($byList -contains $lo) { continue } + if ($covered -contains $lo) { continue } + $matched = $false + foreach ($be in $byList) { + if ($be.StartsWith('.') -and $lo.EndsWith($be)) { $matched = $true; break } + } + if (-not $matched) { $uncovered += $ep } + } + return $uncovered } -if ($Mode -eq 'Auto') { - $Mode = Resolve-ArcMode +if (-not $SkipPKI -and $script:WinHttpProxy) { + Write-Section 'PKI/OCSP/CRL Proxy Bypass' + $uncPki = Test-PkiBypassCoverage + if ($uncPki.Count -eq 0) { + Write-Host ' All PKI endpoints covered by bypass list' -ForegroundColor Green + } + else { + Write-Host ' PKI endpoints MISSING from proxy bypass (TLS will fail):' -ForegroundColor Red + $bFmt = " {0,-9} | {1}" + Write-Host ($bFmt -f 'Status', 'Endpoint') -ForegroundColor Gray + Write-Host (" {0,-9}-+-{1}" -f ('-' * 9), ('-' * 40)) -ForegroundColor DarkGray + foreach ($ep in $uncPki) { + Write-Host ($bFmt -f 'MISSING', $ep) -ForegroundColor Red + Log "PKI bypass MISSING: $ep" Fail + } + Add-Issue -Sev 'CRITICAL' -Cat 'PKI Bypass' ` + -Msg "$($uncPki.Count) PKI endpoint(s) not in proxy bypass" ` + -Fix "Add to GPO NO_PROXY: $($uncPki -join ',')" + } } -Write-Log "Selected mode: $Mode | Region: $Region" Info -NoCount -# Reset stats: the test phase starts here (detection does not count) -$script:Stats.OK = 0 +# ========================================================================= +# 5. ENDPOINT DEFINITIONS +# ========================================================================= + +# Reset stats for test phase +$script:Stats.OK = 0 $script:Stats.Fail = 0 $script:Stats.Warn = 0 -# --------------------------------------------------------------------------- -# Endpoints — organized by functional group -# --------------------------------------------------------------------------- - -# Endpoints that CAN resolve to a private IP via Azure Private Link Scope. -# Everything NOT in this list is always public — do not raise a WARN in Private mode. -$canBePrivateEndpoints = @( +# Endpoints eligible for Private Link resolution +$canBePrivate = [System.Collections.ArrayList]@( 'gbl.his.arc.azure.com' 'agentserviceapi.guestconfiguration.azure.com' 'dc.services.visualstudio.com' 'global.handler.control.monitor.azure.com' ) -# Core Arc (required) — aligned with the Connected Machine agent network-requirements. -# Doc: https://learn.microsoft.com/azure/azure-arc/servers/network-requirements -$coreEndpoints = @( - # AAD / Identity (always; Public) +# --- Core endpoints (always tested) --- +$coreEps = [System.Collections.ArrayList]@( 'login.windows.net' 'login.microsoftonline.com' + "$Region.login.microsoft.com" 'pas.windows.net' - - # ARM (connect/disconnect; Public unless Resource Management Private Link) 'management.azure.com' - - # Arc HIMDS (always; Private via PLS) 'gbl.his.arc.azure.com' - - # Guest Configuration / extension management (always; Private via PLS) 'agentserviceapi.guestconfiguration.azure.com' - - # Agent install/update (Public) 'packages.microsoft.com' 'download.microsoft.com' - - # Telemetry (optional; NOT used on agents 1.24+; Public) 'dc.services.visualstudio.com' ) -# SQL endpoints (optional via -IncludeSQL) — Arc-enabled SQL Server. -# Doc: network-requirements + sql/.../data-collection. All Public; TLS 1.2/1.3. -$sqlEndpoints = @() -if ($IncludeSQL) { - $sqlEndpoints = @( - # Data processing service + telemetry (extensions from Mar/2024 onward) +# GNS global (Public/Gateway modes) +if ($Mode -in 'Public', 'Gateway') { + [void]$coreEps.Add('guestnotificationservice.azure.com') +} + +# Gateway URL +if ($script:GatewayUrl) { + try { + $gwFqdn = ([System.Uri]$script:GatewayUrl).Host + if ($gwFqdn) { [void]$coreEps.Add($gwFqdn) } + } + catch { + $gwFqdn = $script:GatewayUrl -replace 'https?://', '' -replace '/.*', '' + if ($gwFqdn) { [void]$coreEps.Add($gwFqdn) } + } +} + +# --- Extension endpoints (auto-detected) --- +$extEps = @{} + +# SQL Server +if ($script:InstalledExts -contains 'SQL' -or $CheckIncludeAll) { + $extEps['SQL'] = @( "dataprocessingservice.$Region.arcdataservices.com" "telemetry.$Region.arcdataservices.com" - # Legacy: used by extensions until Feb 13, 2024 "san-af-$Region-prod.azurewebsites.net" - # Arc SQL Microsoft Entra authentication (Public). Only needed when using - # Entra auth; NOT a core agent endpoint. Reachable directly, but may be - # blocked on a split-tunnel proxy -> DNS/TCP only (no HTTP probe). 'graph.microsoft.com' ) } -# AMA endpoints (optional via -IncludeAMA) — Azure Monitor Agent. -# Doc: azure-monitor-agent-network-configuration. The .ods and -# .ingest.monitor endpoints require specific IDs -> not generically testable. -$amaEndpoints = @() -if ($IncludeAMA) { - $amaEndpoints = @( - 'global.handler.control.monitor.azure.com' # control service - 'global.prod.microsoftmetrics.com' # metrics service - "$Region.handler.control.monitor.azure.com" # regional DCRs - "$Region.monitoring.azure.com" # custom metrics (optional) +# Defender for SQL (separate from MDE) +if ($script:InstalledExts -contains 'DSQL' -or $CheckIncludeAll) { + if (-not $extEps.ContainsKey('SQL')) { $extEps['SQL'] = @() } + $extEps['SQL'] += @("defender-for-databases.$Region.arcdataservices.com") +} + +# AMA (Azure Monitor Agent) + Dependency Agent +if ($script:InstalledExts -contains 'AMA' -or $script:InstalledExts -contains 'DA' -or $CheckIncludeAll) { + $extEps['AMA'] = @( + 'global.handler.control.monitor.azure.com' + 'global.prod.microsoftmetrics.com' + "$Region.handler.control.monitor.azure.com" + "$Region.monitoring.azure.com" ) } -# MDE endpoints (optional via -IncludeMDE) -$mdeEndpoints = @() -if ($IncludeMDE) { - $mdeEndpoints = @( +# MDE (Microsoft Defender for Endpoint) +if ($script:InstalledExts -contains 'MDE' -or $CheckIncludeAll) { + $extEps['MDE'] = @( 'unitedstates.x.cp.wd.microsoft.com' 'us-v20.events.data.microsoft.com' + 'winatp-gw-cus3.microsoft.com' ) } -# WAC endpoints (optional via -IncludeWAC) -# Note: 'pas.windows.net' is already in $coreEndpoints and $endpointGroupMap -# preserves Core precedence, avoiding duplication in the summary. -$wacEndpoints = @() -if ($IncludeWAC) { - $wacEndpoints = @( - "$Region.service.waconazure.com" +# WAC (Windows Admin Center) +if ($script:InstalledExts -contains 'WAC' -or $CheckIncludeAll) { + $extEps['WAC'] = @("$Region.service.waconazure.com") +} + +# Key Vault extension +if ($script:InstalledExts -contains 'KV' -or $CheckIncludeAll) { + $extEps['KV'] = @('*.vault.azure.net') +} + +# Hybrid Runbook Worker +if ($script:InstalledExts -contains 'HRW' -or $CheckIncludeAll) { + $extEps['HRW'] = @( + '*.azure-automation.net' + '*.agentsvc.azure-automation.net' ) } -# Endpoints that respond to HTTP (L7 validation — 200/400/401/403/404 = reachable). -# Does NOT include graph.microsoft.com: it is the Arc SQL Entra auth endpoint (optional) -# and is often blocked on a split-tunnel proxy; the official Arc SQL connectivity -# test itself validates only DPS + telemetry. -$httpProbeEndpoints = @( - 'login.windows.net' - 'login.microsoftonline.com' - 'management.azure.com' -) -if ($IncludeSQL) { - # Aligned with the official Arc SQL test: DPS expects 200; telemetry expects 401 - # (both treated as reachable here). - $httpProbeEndpoints += "dataprocessingservice.$Region.arcdataservices.com" - $httpProbeEndpoints += "telemetry.$Region.arcdataservices.com" +# Update Manager +if ($script:InstalledExts -contains 'UM' -or $CheckIncludeAll) { + $extEps['UM'] = @("$Region.monitoring.azure.com") +} + +# Guest Attestation +if ($script:InstalledExts -contains 'GA' -or $CheckIncludeAll) { + $extEps['GA'] = @('*.attest.azure.net') +} + +# -SkipExtensions overrides -CheckIncludeAll (user explicitly asked to skip) +if ($SkipExtensions -and $extEps.Count -gt 0) { + $extEps = @{} + Log 'Extension endpoints skipped (-SkipExtensions)' Info -NoCount +} + +# ========================================================================= +# 6. DISCOVER REGIONAL ENDPOINTS (azcmagent check) +# ========================================================================= +# Regional Arc endpoints use unpredictable abbreviations (e.g. eus2, brs, ncus). +# Instead of guessing, we parse 'azcmagent check' output to discover the actual +# endpoints the agent uses. + +Write-Section 'Endpoint Discovery (azcmagent check)' + +$script:AzcmagentCheckExit = $null +$discoveredEps = @() + +if ($azcm) { + $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud') + if ($CheckIncludeAll) { + $checkArgs += @('--extensions', 'all', '--include-all') + } + elseif ($script:InstalledExts -contains 'SQL') { + $checkArgs += @('--extensions', 'sql') + } + if ($Mode -eq 'Private') { $checkArgs += '--enable-pls-check' } + + Write-Host " azcmagent $($checkArgs -join ' ')" -ForegroundColor Gray + try { + $out = & $azcm @checkArgs 2>&1 + $script:AzcmagentCheckExit = $LASTEXITCODE + Save-Log + Add-Content -Path $LogFilePath -Value $out + + # Parse pipe-delimited output to extract endpoint FQDNs + foreach ($line in $out) { + $s = "$line".Trim() + if ($s -match '\|\s*https?://([^\s|/]+)') { + $fqdn = $Matches[1] + if ($fqdn -and $fqdn -notmatch '^(Use Case|Endpoint)') { + $discoveredEps += $fqdn + } + } + } + $discoveredEps = $discoveredEps | Select-Object -Unique + + if ($script:AzcmagentCheckExit -eq 0) { + Write-Host " PASSED - discovered $($discoveredEps.Count) endpoints" -ForegroundColor Green + } + else { + Write-Host " FAILED (exit $($script:AzcmagentCheckExit)) - discovered $($discoveredEps.Count) endpoints" -ForegroundColor Red + Add-Issue -Sev 'HIGH' -Cat 'Agent Check' ` + -Msg "azcmagent check failed (exit $($script:AzcmagentCheckExit))" ` + -Fix 'Review azcmagent check output in log file' + } + } + catch { + Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red + } } +else { + Write-Host ' azcmagent not found - using DNS-based regional endpoint discovery' -ForegroundColor Yellow + + # --- Regional endpoint fallback (no agent) --- + # his.arc.azure.com uses unpredictable abbreviations per region. + # We try a known map + DNS probing to discover the correct FQDN. + $regionAbbrevMap = @{ + 'eastus'='eus'; 'eastus2'='eus2'; 'westus'='wus'; 'westus2'='wus2'; 'westus3'='wus3' + 'centralus'='cus'; 'northcentralus'='ncus'; 'southcentralus'='scus'; 'westcentralus'='wcus' + 'canadacentral'='cac'; 'canadaeast'='cae' + 'brazilsouth'='brs'; 'brazilsoutheast'='brse' + 'northeurope'='neu'; 'westeurope'='weu' + 'uksouth'='uks'; 'ukwest'='ukw' + 'francecentral'='frc'; 'francesouth'='frs' + 'germanywestcentral'='gwc'; 'switzerlandnorth'='szn'; 'switzerlandwest'='szw' + 'norwayeast'='noe'; 'norwaywest'='now'; 'swedencentral'='sec' + 'australiaeast'='aue'; 'australiasoutheast'='ause' + 'eastasia'='ea'; 'southeastasia'='sea' + 'japaneast'='jpe'; 'japanwest'='jpw' + 'koreacentral'='krc'; 'koreasouth'='krs' + 'centralindia'='inc'; 'southindia'='ins'; 'westindia'='inw' + 'southafricanorth'='san'; 'southafricawest'='saw' + 'uaenorth'='uan'; 'uaecentral'='uac' + 'qatarcentral'='qac'; 'polandcentral'='plc'; 'italynorth'='itn' + } + + # Try HIS endpoint: abbreviation first, then full name + $hisCandidates = @() + $rLower = $Region.ToLower() + if ($regionAbbrevMap.ContainsKey($rLower)) { + $hisCandidates += "$($regionAbbrevMap[$rLower]).his.arc.azure.com" + } + $hisCandidates += "$Region.his.arc.azure.com" + + foreach ($hc in $hisCandidates) { + try { + $null = Resolve-DnsName -Name $hc -ErrorAction Stop + $discoveredEps += $hc + Write-Host " Discovered: $hc" -ForegroundColor Green + break + } + catch { } + } + + # GuestConfiguration always uses full region name with -gas suffix + $gcCandidate = "$Region-gas.guestconfiguration.azure.com" + try { + $null = Resolve-DnsName -Name $gcCandidate -ErrorAction Stop + $discoveredEps += $gcCandidate + Write-Host " Discovered: $gcCandidate" -ForegroundColor Green + } + catch { } -# Maps a group per endpoint for the summary. Core has PRECEDENCE: if an endpoint -# appears in more than one group (e.g. pas.windows.net in Core and WAC), we keep 'Core'. + if ($discoveredEps.Count -gt 0) { + Write-Host " Discovered $($discoveredEps.Count) regional endpoint(s) via DNS" -ForegroundColor Green + } + else { + Write-Host ' No regional endpoints discovered (verify -Region parameter)' -ForegroundColor Yellow + Add-Issue -Sev 'WARN' -Cat 'Discovery' ` + -Msg "Could not discover regional endpoints for region '$Region'" ` + -Fix 'Verify -Region parameter or install azcmagent first' + } +} + +# Merge discovered endpoints into core list (avoid duplicates) +foreach ($dep in $discoveredEps) { + if ($coreEps -notcontains $dep) { [void]$coreEps.Add($dep) } + # Mark PLS-eligible patterns + if ($dep -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') { + if ($canBePrivate -notcontains $dep) { [void]$canBePrivate.Add($dep) } + } +} + +# --- Build final endpoint group map --- $endpointGroupMap = @{} -foreach ($ep in $coreEndpoints) { $endpointGroupMap[$ep] = 'Core' } -foreach ($ep in $sqlEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'SQL' } } -foreach ($ep in $amaEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'AMA' } } -foreach ($ep in $mdeEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'MDE' } } -foreach ($ep in $wacEndpoints) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'WAC' } } - -# Dynamic allowlist (Public mode only; under PLS traffic goes via PE) -$dynamicEndpoints = @() +foreach ($ep in $coreEps) { $endpointGroupMap[$ep] = 'Core' } +foreach ($grp in $extEps.Keys) { + foreach ($ep in $extEps[$grp]) { + if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = $grp } + } +} +if (-not $SkipPKI) { + foreach ($ep in $pkiEndpoints) { + if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'PKI' } + } +} + +# --- Dynamic GNS allowlist (Public mode only) --- +$dynamicEps = @() if ($Mode -eq 'Public') { try { - Write-Log 'Fetching dynamic endpoints from guestnotificationservice...' Info -NoCount $uri = "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$Region" - $resp = Invoke-WebRequestSafe -Uri $uri - $dynamicEndpoints = @($resp.Content | ConvertFrom-Json) | Where-Object { $_ } - if ($dynamicEndpoints.Count -gt 0) { - $totalGNS = $dynamicEndpoints.Count - - # Filter: keep only the region's primary endpoints. - # Primary namespaces contain 'p-' (e.g. 1p-, 2p-), secondary contain 's-'. - # Extract cluster IDs from the primaries and filter children by them. - $primaryClusterIds = [System.Collections.ArrayList]::new() - foreach ($dep in $dynamicEndpoints) { - if ($dep -match '^azgn-.+\dp-.+?-(\w+)\.servicebus') { - [void]$primaryClusterIds.Add($Matches[1]) - } + $resp = Invoke-HttpSafe -Uri $uri + $dynamicEps = @($resp.Content | ConvertFrom-Json) | Where-Object { $_ } + if ($dynamicEps.Count -gt 0) { + # Filter to primary namespaces only + $pids = [System.Collections.ArrayList]::new() + foreach ($d in $dynamicEps) { + if ($d -match '^azgn-.+\dp-.+?-(\w+)\.servicebus') { [void]$pids.Add($Matches[1]) } } - - if ($primaryClusterIds.Count -gt 0) { - $filteredGNS = [System.Collections.ArrayList]::new() - foreach ($dep in $dynamicEndpoints) { - if ($dep -match '^azgn-') { - [void]$filteredGNS.Add($dep) # always keep namespace-level - } + if ($pids.Count -gt 0) { + $filtered = [System.Collections.ArrayList]::new() + foreach ($d in $dynamicEps) { + if ($d -match '^azgn-') { [void]$filtered.Add($d) } else { - foreach ($cid in $primaryClusterIds) { - if ($dep -like "*$cid*") { - [void]$filteredGNS.Add($dep) - break - } + foreach ($cid in $pids) { + if ($d -like "*$cid*") { [void]$filtered.Add($d); break } } } } - $skipped = $totalGNS - $filteredGNS.Count - $dynamicEndpoints = @($filteredGNS) - if ($skipped -gt 0) { - Write-Log "Dynamic endpoints obtained: $totalGNS total, $($filteredGNS.Count) primary ($skipped secondary filtered out)" OK - } - else { - Write-Log "Dynamic endpoints obtained: $totalGNS endpoint(s)" OK - } - } - else { - Write-Log "Dynamic endpoints obtained: $totalGNS endpoint(s)" OK - } - - foreach ($dep in $dynamicEndpoints) { - $endpointGroupMap[$dep] = 'GNS' + $dynamicEps = @($filtered) } + foreach ($d in $dynamicEps) { $endpointGroupMap[$d] = 'GNS' } } } catch { - # The dynamic allowlist is AUXILIARY: its unavailability must not break the - # exit code (WARN, not FAIL). Common when forcing -Mode Public on a host that, - # in practice, routes GNS via Private Link / firewall. - Write-Log "Failed to obtain dynamic endpoints (auxiliary allowlist): $($_.Exception.Message)" Warn + Log "GNS dynamic allowlist failed: $($_.Exception.Message)" Warn } } -else { - Write-Log 'Private mode: skipping public allowlist query.' Info -NoCount + +# --- Build combined testable list --- +$allTestable = @() +foreach ($ep in $coreEps) { $allTestable += $ep } +foreach ($grp in $extEps.Keys) { + foreach ($ep in $extEps[$grp]) { $allTestable += $ep } +} +$allTestable += $dynamicEps +if (-not $SkipPKI) { $allTestable += $pkiEndpoints } + +# Separate wildcards (informational, not testable) from concrete FQDNs +$wildcardEps = @($allTestable | Where-Object { $_ -match '^\*\.' } | Select-Object -Unique) +$allTestable = @($allTestable | Where-Object { $_ -notmatch '^\*\.' } | Where-Object { $_ } | Select-Object -Unique) + +# --- HTTP probe endpoints --- +$httpProbeEps = @('login.windows.net', 'login.microsoftonline.com', 'management.azure.com') +if ($extEps.ContainsKey('SQL')) { + $httpProbeEps += "dataprocessingservice.$Region.arcdataservices.com" + $httpProbeEps += "telemetry.$Region.arcdataservices.com" } -$allEndpoints = @( - $coreEndpoints + $sqlEndpoints + $amaEndpoints + $mdeEndpoints + - $wacEndpoints + $dynamicEndpoints | - Where-Object { $_ } | - Select-Object -Unique -) +# --- Agent proxy.bypass => skip HTTP for bypassed endpoints --- +$httpBypassedEps = [System.Collections.ArrayList]::new() +if ($azcm -and $script:EffectiveProxy) { + $bypassCats = @() + try { + $bRaw = (& $azcm config get proxy.bypass 2>$null | Out-String).Trim() + if ($bRaw) { + $bypassCats = $bRaw.Trim('[', ']') -split ',' | + ForEach-Object { $_.Trim() } | Where-Object { $_ } + } + } + catch { } + + $catMap = @{ + 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') + 'ARM' = @('management.azure.com') + 'Arc' = @('gbl.his.arc.azure.com', 'agentserviceapi.guestconfiguration.azure.com') + 'ArcData' = @("dataprocessingservice.$Region.arcdataservices.com", + "telemetry.$Region.arcdataservices.com") + 'AMA' = @('global.handler.control.monitor.azure.com', + "$Region.handler.control.monitor.azure.com") + } + foreach ($cat in $bypassCats) { + if ($catMap.ContainsKey($cat)) { + foreach ($ep in $catMap[$cat]) { + if ($httpBypassedEps -notcontains $ep) { [void]$httpBypassedEps.Add($ep) } + } + } + } +} + +# ========================================================================= +# 7. ENDPOINT TESTS: DNS + TCP/443 +# ========================================================================= -Write-Log "Total endpoints to test: $($allEndpoints.Count)" Info -NoCount -[void]$script:LogBuffer.Add('') +Write-Banner "TESTING $($allTestable.Count) ENDPOINTS" -# --------------------------------------------------------------------------- -# Tests: DNS + TCP/443 (consistency check against the detected mode) -# --------------------------------------------------------------------------- -foreach ($ep in $allEndpoints) { +$pi = 0 +foreach ($ep in $allTestable) { $ep = $ep.Trim() if (-not $ep) { continue } + $pi++ - Write-Verbose "Testing: $ep" + $grp = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Core' } + Add-Result -Endpoint $ep -Group $grp - [void]$script:LogBuffer.Add('-' * 60) - $group = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Dyn' } - Add-Result -Endpoint $ep -Group $group + $pct = [math]::Round(($pi / $allTestable.Count) * 100) + $epShort = if ($ep.Length -gt 56) { $ep.Substring(0, 53) + '...' } else { $ep } + Write-Host ("`r [{0,3}%] {1,-58}" -f $pct, $epShort) -NoNewline -ForegroundColor Gray - # DNS (with 1 retry on a transient failure, e.g. SERVFAIL when resolving many names) + # --- DNS --- $dns = $null $dnsErr = $null - foreach ($attempt in 1..2) { - try { $dns = Resolve-DnsName -Name $ep -ErrorAction Stop; $dnsErr = $null; break } - catch { $dnsErr = $_; if ($attempt -lt 2) { Start-Sleep -Milliseconds 300 } } + foreach ($a in 1..2) { + try { + $dns = Resolve-DnsName -Name $ep -ErrorAction Stop + $dnsErr = $null + break + } + catch { + $dnsErr = $_ + if ($a -lt 2) { Start-Sleep -Milliseconds 300 } + } } + if ($dnsErr) { - # Dynamic endpoints (GNS) are AUXILIARY: a DNS failure on them becomes WARN - # (not FAIL), because a transient SERVFAIL when resolving dozens of - # 'servicebus' names must not break the exit code. Other groups stay FAIL. - $existingD = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($group -eq 'GNS') { - Write-Log "DNS WARN $ep - $($dnsErr.Exception.Message) (dynamic/auxiliary endpoint)" Warn - if ($existingD) { $existingD.DNS = 'WARN' } - } - else { - Write-Log "DNS FAIL $ep - $($dnsErr.Exception.Message)" Fail - if ($existingD) { $existingD.DNS = 'FAIL' } + $lv = if ($grp -eq 'GNS') { 'Warn' } else { 'Fail' } + Log "DNS $lv $ep - $($dnsErr.Exception.Message)" $lv + $rr = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($rr) { $rr.DNS = $lv.ToUpper() } + if ($lv -eq 'Fail') { + Add-Issue -Sev 'HIGH' -Cat 'DNS' -Msg "Cannot resolve $ep" -Fix 'Check DNS/firewall' } continue } - # Prefer IPv4 (A record): Azure Private Link and most Arc endpoints are resolved - # by A record. A public AAAA (IPv6) may coexist with the private A; if chosen, it - # causes an incorrect PUBLIC classification and tests over a possibly - # nonexistent/unrouted IPv6 path. $rec = $dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1 if (-not $rec) { $rec = $dns | Where-Object IPAddress | Select-Object -First 1 } $ip = $rec.IPAddress - $kind = if (Test-IsPrivateIp -Ip $ip) { 'PRIVATE' } else { 'PUBLIC' } - - # Update result - $existing = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing) { $existing.IP = $ip; $existing.Type = $kind } - - # DNS vs mode mismatch alert - # Only endpoints in $canBePrivateEndpoints should resolve to a private IP. - # All others (AAD, ARM, CDN, SQL, AMA, MDE, WAC, GNS) are always public. - $canBePrivate = $canBePrivateEndpoints -contains $ep - $mismatch = $false - if ($Mode -eq 'Private' -and $kind -eq 'PUBLIC' -and $canBePrivate) { - $mismatch = $true - } - elseif ($Mode -eq 'Public' -and $kind -eq 'PRIVATE') { - $mismatch = $true - } - if ($mismatch) { - Write-Log "DNS WARN $ep -> $ip [$kind] (expected the opposite for $Mode mode)" Warn - $existing2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing2) { $existing2.DNS = 'WARN' } + $kind = if (Test-IsPrivateIp $ip) { 'PRIV' } else { 'PUB' } + + $rr = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($rr) { $rr.IP = $ip; $rr.Type = $kind } + + $cbp = $canBePrivate -contains $ep + $mm = ($Mode -eq 'Private' -and $kind -eq 'PUB' -and $cbp) -or + ($Mode -eq 'Public' -and $kind -eq 'PRIV') + if ($mm) { + Log "DNS WARN $ep -> $ip [$kind] mode mismatch" Warn + $rr2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($rr2) { $rr2.DNS = 'WARN' } } else { - Write-Log "DNS OK $ep -> $ip [$kind]" OK - $existing2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing2) { $existing2.DNS = 'OK' } + Log "DNS OK $ep -> $ip" OK + $rr2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($rr2) { $rr2.DNS = 'OK' } } - # TCP/443 (TcpClient with timeout — much faster than Test-NetConnection) - $tcpSw = [System.Diagnostics.Stopwatch]::StartNew() - $tcpOk = Test-TcpPort -ComputerName $ep -Port 443 -TimeoutMs 5000 - $tcpSw.Stop() - $latencyMs = [math]::Round($tcpSw.Elapsed.TotalMilliseconds, 0) - - $existing3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($tcpOk) { - Write-Log "TCP OK ${ep}:443 (${latencyMs}ms)" OK - if ($existing3) { $existing3.TCP = 'OK'; $existing3.Latency = "${latencyMs}ms" } + # --- TCP/443 --- + # Note: TCP tests L3/L4 reachability directly (not via proxy). + # In explicit proxy setups, this validates the network path through the firewall. + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $ok = Test-TcpPort -H $ep -P 443 -T 5000 + $sw.Stop() + $ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 0) + + $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($ok) { + Log "TCP OK ${ep}:443 (${ms}ms)" OK + if ($rr3) { $rr3.TCP = 'OK'; $rr3.Latency = "${ms}ms" } } else { - Write-Log "TCP FAIL ${ep}:443 (timeout/refused)" Fail - if ($existing3) { $existing3.TCP = 'FAIL'; $existing3.Latency = 'timeout' } + Log "TCP FAIL ${ep}:443" Fail + if ($rr3) { $rr3.TCP = 'FAIL'; $rr3.Latency = 'timeout' } + Add-Issue -Sev 'HIGH' -Cat 'TCP' -Msg "Cannot connect to ${ep}:443" -Fix 'Check firewall/proxy rules' } } +Write-Host '' # Clear progress line -# --------------------------------------------------------------------------- -# HTTP tests (401/403/400 are treated as success: endpoint requires auth) -# Detects azcmagent proxy.bypass to skip HTTP tests on bypassed endpoints -# --------------------------------------------------------------------------- -$proxyBypassCategories = @() -$azcmPath = Get-AzcmagentPath -if ($azcmPath -and $script:EffectiveProxy) { - try { - $bypassRaw = & $azcmPath config get proxy.bypass 2>$null - $bypassRawStr = if ($bypassRaw) { ($bypassRaw | Out-String).Trim() } else { '' } - if ($bypassRawStr) { - $bypassClean = $bypassRawStr.Trim('[', ']') - $proxyBypassCategories = $bypassClean -split ',' | - ForEach-Object { $_.Trim() } | - Where-Object { $_ } - } - } - catch { } -} - -# Map of bypass categories -> affected endpoints (per the official doc: -# https://learn.microsoft.com/azure/azure-arc/servers/manage-agent-proxy-settings). -# IMPORTANT: 'graph.microsoft.com' is NOT covered by any bypass — the agent -# uses the proxy for it; therefore it must NOT be skipped in the HTTP tests. -# 'ArcData' is valid from agent 1.36 onward; in earlier versions the -# arcdataservices endpoints fell under the 'Arc' category. -$bypassCategoryEndpoints = @{ - 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') - 'ARM' = @('management.azure.com') - 'AMA' = @( - 'global.handler.control.monitor.azure.com' - "$Region.handler.control.monitor.azure.com" - 'management.azure.com' - "$Region.monitoring.azure.com" - ) - 'Arc' = @('gbl.his.arc.azure.com', 'agentserviceapi.guestconfiguration.azure.com') - 'ArcData' = @( - "dataprocessingservice.$Region.arcdataservices.com" - "telemetry.$Region.arcdataservices.com" - ) -} - -$httpBypassedEndpoints = [System.Collections.ArrayList]::new() -foreach ($cat in $proxyBypassCategories) { - if ($bypassCategoryEndpoints.ContainsKey($cat)) { - foreach ($bep in $bypassCategoryEndpoints[$cat]) { - if ($httpBypassedEndpoints -notcontains $bep) { - [void]$httpBypassedEndpoints.Add($bep) - } - } - } -} +# ========================================================================= +# 8. HTTP TESTS + PKI PROBE +# ========================================================================= -foreach ($ep in $httpProbeEndpoints) { +foreach ($ep in $httpProbeEps) { $ep = $ep.Trim() if (-not $ep) { continue } - - # If the endpoint is in the azcmagent bypass and we use a proxy, an HTTP test via proxy would give a false positive - if ($httpBypassedEndpoints -contains $ep) { - Add-Result -Endpoint $ep -HTTP 'SKIP (bypass)' - Write-Log "HTTP SKIP $ep (azcmagent proxy.bypass covers this endpoint — agent does not use a proxy)" Info -NoCount + if ($httpBypassedEps -contains $ep) { + Add-Result -Endpoint $ep -HTTP 'SKIP' + Log "HTTP SKIP $ep (bypass)" Info -NoCount continue } - - [void]$script:LogBuffer.Add('-' * 60) - Add-Result -Endpoint $ep - - $sw = [System.Diagnostics.Stopwatch]::StartNew() try { - $resp = Invoke-WebRequestSafe -Uri "https://$ep" -TimeoutSec 10 - $sw.Stop() - $elapsed = [math]::Round($sw.Elapsed.TotalSeconds, 2) - Write-Log "HTTP OK $ep -> $($resp.StatusCode) in ${elapsed}s" OK - $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing4) { $existing4.HTTP = "OK ($($resp.StatusCode))" } + $resp = Invoke-HttpSafe -Uri "https://$ep" -Timeout 10 + Log "HTTP OK $ep -> $($resp.StatusCode)" OK + Add-Result -Endpoint $ep -HTTP "OK($($resp.StatusCode))" } catch { - if ($sw.IsRunning) { $sw.Stop() } $code = $null if ($_.Exception.Response) { try { $code = [int]$_.Exception.Response.StatusCode } catch { } } if ($code -in 400, 401, 403, 404) { - Write-Log "HTTP OK $ep -> $code (expected without auth/without root handler)" OK - $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing4) { $existing4.HTTP = "OK ($code)" } - } - elseif ($code) { - Write-Log "HTTP FAIL $ep -> $code" Fail - $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing4) { $existing4.HTTP = "FAIL ($code)" } + Log "HTTP OK $ep -> $code" OK + Add-Result -Endpoint $ep -HTTP "OK($code)" } else { - Write-Log "HTTP FAIL $ep - $($_.Exception.Message)" Fail - $existing4 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($existing4) { $existing4.HTTP = 'FAIL' } + Log "HTTP FAIL $ep" Fail + Add-Result -Endpoint $ep -HTTP 'FAIL' + Add-Issue -Sev 'MEDIUM' -Cat 'HTTP' -Msg "HTTP failed: $ep" -Fix 'Check proxy/firewall app rules' } } } -# --------------------------------------------------------------------------- -# azcmagent check -# --------------------------------------------------------------------------- -[void]$script:LogBuffer.Add('=' * 60) -$azcm = Get-AzcmagentPath -if ($azcm) { - $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud') - if ($CheckIncludeAll) { - # Official doc: '--extensions' and '--include-all' are ORTHOGONAL. - # --extensions all -> endpoints for ALL extensions (SQL, etc.) - # --include-all -> EXTENDED use cases (e.g. Windows Server PAYG) - # We combine both for full coverage. - $checkArgs += @('--extensions', 'all', '--include-all') - } - elseif ($IncludeSQL) { - $checkArgs += @('--extensions', 'sql') - } - if ($Mode -eq 'Private') { $checkArgs += '--enable-pls-check' } +# PKI HTTP probe — tests the REAL path SCHANNEL will use: +# If oneocsp.microsoft.com is in bypass → test DIRECT (no proxy) +# If NOT in bypass → test via WinHTTP proxy (likely fails on explicit proxy) +if (-not $SkipPKI -and $script:WinHttpProxy) { + $uncPkiNow = Test-PkiBypassCoverage + $ocspBypassed = $uncPkiNow -notcontains 'oneocsp.microsoft.com' - Write-Log "Running: azcmagent $($checkArgs -join ' ')" Info -NoCount - Save-LogBuffer # ensure ordering: header before the binary output - try { - $out = & $azcm @checkArgs 2>&1 - Add-Content -Path $LogFilePath -Value $out - if ($LASTEXITCODE -eq 0) { - Write-Log 'azcmagent check completed (exit 0).' OK + if ($ocspBypassed) { + # Endpoint is in bypass — SCHANNEL will connect DIRECT, not via proxy + try { + $probeParams = @{ + Uri = 'http://oneocsp.microsoft.com' + Method = 'Get'; UseBasicParsing = $true + TimeoutSec = 5; ErrorAction = 'Stop' + } + if ($PSVersionTable.PSVersion.Major -ge 6) { + $probeParams['NoProxy'] = $true + } + else { + $savedWP = [System.Net.WebRequest]::DefaultWebProxy + [System.Net.WebRequest]::DefaultWebProxy = $null + } + $resp = Invoke-WebRequest @probeParams + if ($PSVersionTable.PSVersion.Major -lt 6) { + [System.Net.WebRequest]::DefaultWebProxy = $savedWP + } + Log "PKI probe OK direct (bypass active)" OK + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($($resp.StatusCode))" } - else { - Write-Log "azcmagent check finished with exit $LASTEXITCODE." Fail + catch { + try { if ($PSVersionTable.PSVersion.Major -lt 6) { [System.Net.WebRequest]::DefaultWebProxy = $savedWP } } catch { } + # OCSP responders return 4xx on bare GET — any HTTP response = reachable + $code = $null + try { + if ($_.Exception -and $_.Exception.Response) { + $code = [int]$_.Exception.Response.StatusCode + } + } catch { } + if ($code -and $code -ge 200 -and $code -lt 600) { + Log "PKI probe OK direct -> $code (bypass active)" OK + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($code)" + } + else { + $msg = $_.Exception.Message + Log "PKI probe FAIL direct: $msg" Fail + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'FAIL' + Add-Issue -Sev 'HIGH' -Cat 'PKI Direct' ` + -Msg 'OCSP unreachable via direct path (bypass active but no route)' ` + -Fix 'Ensure firewall network/application rules allow direct HTTP:80 to PKI endpoints' + } } } - catch { - Write-Log "azcmagent check failed: $($_.Exception.Message)" Fail + else { + # Endpoint NOT in bypass — SCHANNEL sends via proxy (will likely fail on explicit proxy) + try { + $resp = Invoke-HttpSafe -Uri 'http://oneocsp.microsoft.com' -Timeout 5 -UseProxy $script:WinHttpProxy + Log "PKI probe OK via WinHTTP proxy" OK + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($($resp.StatusCode))" + } + catch { + $msg = $_.Exception.Message + if ($msg -match '407') { + Log "PKI probe: 407 proxy auth" Warn + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'WARN' + } + else { + Log "PKI probe FAIL via proxy: $msg" Fail + Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'FAIL' + Add-Issue -Sev 'CRITICAL' -Cat 'PKI Proxy' ` + -Msg 'OCSP unreachable via WinHTTP proxy (non-proxy request on proxy port)' ` + -Fix 'Add PKI endpoints to proxy bypass (GPO NO_PROXY)' + } + } } } -else { - Write-Log 'azcmagent.exe not found - skipping check.' Warn + +# azcmagent check result (already ran during discovery) +if ($null -ne $script:AzcmagentCheckExit) { + if ($script:AzcmagentCheckExit -eq 0) { Log 'azcmagent check: exit 0' OK } + else { Log "azcmagent check: exit $($script:AzcmagentCheckExit)" Fail } +} +elseif (-not $azcm) { + Log 'azcmagent not found - skipping check' Warn } -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- -[void]$script:LogBuffer.Add('=' * 60) -Write-Log ("Summary: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` - $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) Info -NoCount -Write-Log "Script finished at $(Get-Date -Format o)" Info -NoCount -Save-LogBuffer +Save-Log -# --------------------------------------------------------------------------- -# Results table (console + log) -# --------------------------------------------------------------------------- -$tableObjects = $script:Results | ForEach-Object { [pscustomobject]$_ } +# ========================================================================= +# 9. RESULTS TABLE +# ========================================================================= -Write-Host '' -Write-Host '=================== SUMMARY ===================' -ForegroundColor Cyan +Write-Banner 'RESULTS' -$rowFormat = "{0,-5} {1,-55} {2,-26} {3,-8} {4,-5} {5,-5} {6,-12} {7,-9}" -Write-Host ($rowFormat -f 'Group', 'Endpoint', 'IP', 'Type', 'DNS', 'TCP', 'HTTP', 'Latency') -ForegroundColor Cyan -Write-Host ($rowFormat -f ('-' * 5), ('-' * 55), ('-' * 26), ('-' * 8), ('-' * 5), ('-' * 5), ('-' * 12), ('-' * 9)) -ForegroundColor DarkGray +$tbl = $script:Results | ForEach-Object { [pscustomobject]$_ } -foreach ($r in $tableObjects) { - $hasFail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') - $hasWarn = ($r.DNS -eq 'WARN') - $color = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } - Write-Host ($rowFormat -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) -ForegroundColor $color -} +# Sort by group priority +$grps = $tbl | Group-Object Group | Sort-Object @{ Expression = { + switch ($_.Name) { + 'Core' { 0 }; 'PKI' { 1 }; 'SQL' { 2 }; 'AMA' { 3 }; 'MDE' { 4 } + 'WAC' { 5 }; 'KV' { 6 }; 'HRW' { 7 }; 'UM' { 8 }; 'GA' { 9 } + 'GNS' { 10 }; default { 11 } + } +} } + +# Pipe-delimited table (azcmagent check style) +$hf = " {0,-5} | {1,-50} | {2,-16} | {3,-4} | {4,-9} | {5,-7}" +Write-Host ($hf -f 'Group', 'Endpoint', 'IP', 'Type', 'Result', 'Latency') -ForegroundColor Cyan +Write-Host (" {0,-5}-+-{1,-50}-+-{2,-16}-+-{3,-4}-+-{4,-9}-+-{5,-7}" -f ` + ('-' * 5), ('-' * 50), ('-' * 16), ('-' * 4), ('-' * 9), ('-' * 7)) -ForegroundColor DarkGray + +foreach ($g in $grps) { + foreach ($r in $g.Group) { + $dnsOk = $r.DNS -notin @('FAIL', 'WARN', '-') + $tcpOk = $r.TCP -notin @('FAIL', '-') + $httpOk = ($r.HTTP -eq '-') -or ($r.HTTP -like 'OK*') -or ($r.HTTP -eq 'SKIP') + $fail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') + $warn = ($r.DNS -eq 'WARN') -or ($r.HTTP -like 'WARN*') + + # Compose result column (mimics azcmagent: Reachable / Unreachable / Warning) + if ($fail) { + $detail = @() + if ($r.DNS -eq 'FAIL') { $detail += 'DNS' } + if ($r.TCP -eq 'FAIL') { $detail += 'TCP' } + if ($r.HTTP -like 'FAIL*') { $detail += 'HTTP' } + $result = "FAIL($($detail -join ','))" + } + elseif ($warn) { + $result = 'Warning' + } + elseif ($r.HTTP -eq 'SKIP') { + $result = 'Reachable*' + } + else { + $result = 'Reachable' + } -Write-Host '' -Write-Host ("Totals: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` - $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) -ForegroundColor Cyan + $c = if ($fail) { 'Red' } elseif ($warn) { 'Yellow' } else { 'Green' } + Write-Host ($hf -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $result, $r.Latency) -ForegroundColor $c + } +} -if ($script:EffectiveProxy) { - Write-Host "Proxy used: $($script:EffectiveProxy)" -ForegroundColor DarkGray +# Wildcard endpoints (informational) +if ($wildcardEps.Count -gt 0) { + Write-Host '' + $wFmt = " {0,-5} | {1,-50} | {2}" + Write-Host ($wFmt -f 'Group', 'Wildcard Endpoint', 'Note') -ForegroundColor DarkGray + Write-Host (" {0,-5}-+-{1,-50}-+-{2}" -f ('-' * 5), ('-' * 50), ('-' * 30)) -ForegroundColor DarkGray + foreach ($w in $wildcardEps) { + $wg = if ($endpointGroupMap.ContainsKey($w)) { $endpointGroupMap[$w] } else { '-' } + Write-Host ($wFmt -f $wg, $w, 'Requires firewall rule (not testable)') -ForegroundColor DarkGray + } } -else { - Write-Host 'Proxy used: Direct (no proxy)' -ForegroundColor DarkGray + +# ========================================================================= +# 10. ISSUES +# ========================================================================= + +if ($script:Issues.Count -gt 0) { + Write-Banner "ISSUES ($($script:Issues.Count))" + $iFmt = " {0,-3} | {1,-8} | {2,-12} | {3}" + Write-Host ($iFmt -f '#', 'Severity', 'Category', 'Message') -ForegroundColor Cyan + Write-Host (" {0,-3}-+-{1,-8}-+-{2,-12}-+-{3}" -f ('-' * 3), ('-' * 8), ('-' * 12), ('-' * 50)) -ForegroundColor DarkGray + $ix = 0 + foreach ($iss in $script:Issues) { + $ix++ + $sc = switch ($iss.Severity) { + 'CRITICAL' { 'Red' }; 'HIGH' { 'Red' } + 'MEDIUM' { 'Yellow' }; 'WARN' { 'Yellow' } + default { 'Gray' } + } + Write-Host ($iFmt -f $ix, $iss.Severity, $iss.Category, $iss.Message) -ForegroundColor $sc + if ($iss.Fix) { + Write-Host (" {0,-3} | {1,-8} | {2,-12} | Fix: {3}" -f '', '', '', $iss.Fix) -ForegroundColor DarkCyan + } + } } -# Append table to the log file -$tableString = $tableObjects | Format-Table -AutoSize | Out-String -Add-Content -Path $LogFilePath -Value '' -Add-Content -Path $LogFilePath -Value '=================== SUMMARY ===================' -Add-Content -Path $LogFilePath -Value $tableString.TrimEnd() -Add-Content -Path $LogFilePath -Value ("Totals: OK={0} Fail={1} Warn={2} Mode={3} Region={4}" -f ` - $script:Stats.OK, $script:Stats.Fail, $script:Stats.Warn, $Mode, $Region) +# ========================================================================= +# 11. FINAL SUMMARY +# ========================================================================= + +Write-Host '' +Write-Host ('=' * 74) -ForegroundColor DarkCyan + +$fc = $script:Stats.Fail +$wc = $script:Stats.Warn +$oc = $script:Stats.OK +$ic = $script:Issues.Count + +# Only CRITICAL/HIGH issues cause FAIL; WARN/MEDIUM issues cause WARN status but exit 0 +$critIssues = @($script:Issues | Where-Object { $_.Severity -in @('CRITICAL', 'HIGH') }) +$hasFail = ($fc -gt 0) -or ($critIssues.Count -gt 0) +$hasWarn = ($wc -gt 0) -or ($ic -gt $critIssues.Count) +$summColor = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } +$statusText = if ($hasFail) { 'FAIL' } elseif ($hasWarn) { 'WARN' } else { 'PASS' } + +$gwTag = if ($script:GatewayUrl) { ' [GW]' } else { '' } +$preTag = if ($script:PreOnboarding) { ' [PRE-ONBOARDING]' } else { '' } +Write-Host (" STATUS: {0} | OK:{1} Fail:{2} Warn:{3} Issues:{4} | {5} {6}{7}{8}" -f ` + $statusText, $oc, $fc, $wc, $ic, $Mode, $Region, $gwTag, $preTag) -ForegroundColor $summColor + +if ($script:InstalledExts.Count -gt 0) { + Write-Host " Extensions: $($script:InstalledExts -join ', ')" -ForegroundColor DarkGray +} if ($script:EffectiveProxy) { - Add-Content -Path $LogFilePath -Value "Proxy used: $($script:EffectiveProxy)" + Write-Host " Proxy: $($script:EffectiveProxy)" -ForegroundColor DarkGray } -else { - Add-Content -Path $LogFilePath -Value 'Proxy used: Direct (no proxy)' +Write-Host " Log: $LogFilePath" -ForegroundColor DarkGray +Write-Host ('=' * 74) -ForegroundColor DarkCyan + +# --- Append to log file --- +$ts = $tbl | Format-Table -AutoSize | Out-String +Add-Content -Path $LogFilePath -Value '' +Add-Content -Path $LogFilePath -Value '=================== RESULTS ===================' +Add-Content -Path $LogFilePath -Value $ts.TrimEnd() +Add-Content -Path $LogFilePath -Value ("Status: $statusText | OK=$oc Fail=$fc Warn=$wc Issues=$ic | $Mode $Region$gwTag") + +if ($ic -gt 0) { + Add-Content -Path $LogFilePath -Value '' + Add-Content -Path $LogFilePath -Value '=================== ISSUES ===================' + foreach ($iss in $script:Issues) { + Add-Content -Path $LogFilePath -Value "[$($iss.Severity)] $($iss.Category): $($iss.Message)" + if ($iss.Fix) { Add-Content -Path $LogFilePath -Value " Fix: $($iss.Fix)" } + } } -Write-Host "`nFull log: $LogFilePath" -ForegroundColor Cyan -exit ([int]($script:Stats.Fail -gt 0)) +exit ([int]$hasFail) From 9f25239cc4445a888cef15ce3fd70d9e3316a054 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa Date: Wed, 8 Jul 2026 09:42:21 -0300 Subject: [PATCH 08/10] feat: ArcEndpointCheck v2.0.0 - Full rewrite with 7-phase diagnostic, Gateway/PLS/proxy support, TLS validation, extension endpoint coverage --- .../arc_endpoint_check/ArcEndpointCheck.ps1 | 624 +++++++++++++++--- 1 file changed, 525 insertions(+), 99 deletions(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index 7eb64cbe..52e26800 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -14,13 +14,37 @@ - Private: Azure Private Link Scope (PLS) - Gateway: Azure Arc Gateway (reduces endpoints to ~7 FQDNs) + Diagnostic sequence (execution order): + 1. System & Agent Context - OS, agent version/status, mode, extensions + 2. Proxy Chain - Detects and displays ALL proxy sources: + - WinHTTP (OS/GPO) → used by SCHANNEL (PKI/CRL/OCSP) + - azcmagent proxy.url → used by Arc Agent core + - HTTPS_PROXY env → used by Extensions (.NET/Python) + - Upstream proxy → used in Gateway chain + Precedence for tests: -ProxyUrl > azcmagent > HTTPS_PROXY > WinHTTP + 3. TLS & Crypto - SCHANNEL registry, .NET Framework, cipher suites, + TLS 1.2 handshake test, registry dump (actual vs recommended) + 4. PKI/OCSP/CRL Bypass - Validates which PKI endpoints are in proxy bypass: + - Bypassed → SCHANNEL connects DIRECT (test without proxy) + - Not bypassed → SCHANNEL uses WinHTTP proxy (test via proxy) + Detects "non-proxy request on proxy port" failures + 5. Endpoint Discovery - azcmagent check + DNS-based regional discovery + 6. Connectivity Tests - Test strategy per proxy scenario: + - TCP/443: Direct L3/L4 (never uses proxy — validates firewall) + - HTTP probe: Via effective proxy (validates app-layer path) + - Gateway tunneled: Skips TCP (traffic goes localhost:40343) + - PKI probe: Direct or via WinHTTP (per bypass list) + - SQL TLS probe: TLS 1.2 handshake to arcdataservices.com + 7. Results & Issues - Summary table, issues with fix recommendations + Validates: - - Core Arc agent endpoints (HIMDS, GuestConfig, GNS, AAD, ARM) + - Core Arc agent endpoints (HIMDS, GuestConfig, GNS, AAD, ARM, MCR) - Regional endpoints discovered from 'azcmagent check' + - Extension endpoints: SQL, AMA, MDE, WAC, KV, HRW, UM, GA, Defender for SQL - PKI/OCSP/CRL proxy bypass (detects "non-proxy request on proxy port") - - TLS version (1.2+ required per Microsoft docs) - - Extension endpoints based on installed extensions (auto-detected) - - Arc Gateway URL when gateway mode is active + - TLS 1.2+ with required GCM cipher suites (agent 1.56+) + - Arc Gateway tunneled vs direct endpoint classification + - SQL Arc specific TLS 1.2 handshake to arcdataservices.com Run with ZERO parameters for full auto-detection: PS> .\arcendpointcheck.ps1 @@ -43,6 +67,10 @@ .PARAMETER SkipExtensions Skips extension endpoint testing. +.PARAMETER GatewayUrl + Arc Gateway URL for pre-onboarding (when agent is not installed). + Auto-detected from azcmagent if omitted. + .PARAMETER CheckIncludeAll Makes 'azcmagent check' use '--extensions all --include-all'. @@ -54,15 +82,27 @@ PS> .\arcendpointcheck.ps1 -Region brazilsouth -Mode Private Forces region and mode override. +.EXAMPLE + PS> .\arcendpointcheck.ps1 -Region eastus2 -Mode Gateway -GatewayUrl https://mygateway.gw.arc.azure.com -ProxyUrl http://10.0.1.4:8443 + Pre-onboarding Gateway: agent not installed but gateway URL is known. + .EXAMPLE PS> .\arcendpointcheck.ps1 -Region eastus2 -Mode Private -ProxyUrl http://10.0.1.4:8443 -CheckIncludeAll Pre-onboarding: agent not installed. Specify region, mode, proxy, and test all extensions. .NOTES - Requires PowerShell 5.1+ on Windows. - Ref: https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated - https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway - https://learn.microsoft.com/azure/azure-arc/azure-firewall-explicit-proxy + Requires PowerShell 5.1+ on Windows (Server 2012 R2+ with WMF 5.1, or Server 2016+ native). + Minimum OS: Windows Server 2012 R2 (with WMF 5.1 installed). + Does NOT require Administrator (recommended but not mandatory). + + References: + - https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated + - https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway + - https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues + - https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint + - https://learn.microsoft.com/azure/azure-monitor/agents/azure-monitor-agent-network-configuration + - https://learn.microsoft.com/defender-endpoint/configure-proxy-internet + - https://learn.microsoft.com/entra/identity/hybrid/connect/reference-connect-tls-enforcement .LINK https://azurearcjumpstart.com @@ -77,6 +117,8 @@ param( [string]$ProxyUrl, + [string]$GatewayUrl, + [string]$LogFilePath = "C:\temp\ArcEndpointCheck_$($env:COMPUTERNAME).txt", [switch]$SkipPKI, @@ -89,6 +131,27 @@ param( # ========================================================================= $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' +$script:Version = '2.0.0' +$script:Updated = '2026-07-08' + +# Enable TLS 1.2 for this session (required on systems without SchUseStrongCrypto) +# 3072 = [Net.SecurityProtocolType]::Tls12 +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 + +# --- Prerequisites Check --- +$script:IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $script:IsAdmin) { + Write-Host ' [WARN] Not running as Administrator. Some checks may be limited.' -ForegroundColor Yellow + Write-Host ' Recommend: Right-click PowerShell > Run as Administrator' -ForegroundColor DarkYellow + Write-Host '' +} + +# Verify Resolve-DnsName is available (DnsClient module, Server 2012+ / Win8+) +if (-not (Get-Command 'Resolve-DnsName' -ErrorAction SilentlyContinue)) { + Write-Host ' [ERROR] Resolve-DnsName not available. Requires Windows Server 2012+ / Win8+.' -ForegroundColor Red + Write-Host ' This script cannot run on this OS.' -ForegroundColor Red + exit 1 +} $logDir = Split-Path -Path $LogFilePath -Parent if ($logDir -and -not (Test-Path $logDir)) { @@ -155,7 +218,7 @@ function Add-Result { param( [string]$Endpoint, [string]$Group = 'Core', [string]$IP = '-', [string]$Type = '-', [string]$DNS = '-', [string]$TCP = '-', - [string]$HTTP = '-', [string]$Latency = '-' + [string]$HTTP = '-', [string]$Latency = '-', [string]$Path = '-' ) $ex = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } if ($ex) { @@ -165,11 +228,12 @@ function Add-Result { if ($TCP -ne '-') { $ex.TCP = $TCP } if ($HTTP -ne '-') { $ex.HTTP = $HTTP } if ($Latency -ne '-') { $ex.Latency = $Latency } + if ($Path -ne '-') { $ex.Path = $Path } } else { [void]$script:Results.Add([ordered]@{ Endpoint = $Endpoint; Group = $Group; IP = $IP; Type = $Type - DNS = $DNS; TCP = $TCP; HTTP = $HTTP; Latency = $Latency + DNS = $DNS; TCP = $TCP; HTTP = $HTTP; Latency = $Latency; Path = $Path }) } } @@ -237,11 +301,37 @@ function Test-IsPrivateIp { ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) } +function Test-IsGatewayTunneled { + param([string]$Endpoint) + if ($Mode -ne 'Gateway') { return $false } + foreach ($pattern in $script:GatewayTunneledPatterns) { + if ($pattern.StartsWith('*.')) { + $suffix = $pattern.Substring(1) # e.g. '.his.arc.azure.com' + if ($Endpoint.EndsWith($suffix) -or $Endpoint -eq $pattern.Substring(2)) { return $true } + } + elseif ($Endpoint -eq $pattern) { return $true } + } + return $false +} + # ========================================================================= -# 1. AGENT DETECTION (region, mode, gateway, extensions) +# PHASE 1: SYSTEM & AGENT CONTEXT # ========================================================================= Write-Banner 'AZURE ARC ENDPOINT CHECK' +Write-Host " Version $($script:Version) ($($script:Updated))" -ForegroundColor DarkGray +Write-Host '' +Write-Host ' Diagnostic sequence:' -ForegroundColor DarkGray +Write-Host ' 1. System & Agent Context (OS, agent version, mode, extensions)' -ForegroundColor DarkGray +Write-Host ' 2. Proxy Chain (WinHTTP -> Agent -> Env -> Gateway)' -ForegroundColor DarkGray +Write-Host ' 3. TLS & Crypto (SCHANNEL + .NET + ciphers + registry dump)' -ForegroundColor DarkGray +Write-Host ' 4. PKI/OCSP/CRL Bypass (certificate validation path)' -ForegroundColor DarkGray +Write-Host ' 5. Endpoint Discovery (azcmagent check + regional)' -ForegroundColor DarkGray +Write-Host ' 6. Connectivity Tests (DNS + TCP + HTTP probes)' -ForegroundColor DarkGray +Write-Host ' 7. Results & Issues (summary table)' -ForegroundColor DarkGray +Write-Host '' + +Write-Section 'System & Agent' Write-Status 'Host' $env:COMPUTERNAME Write-Status 'Time' (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') @@ -251,6 +341,17 @@ $script:WinHttpBypass = $null $script:AgentJson = $null $script:GatewayUrl = $null $script:InstalledExts = @() +$script:AgentVersion = $null + +# Endpoints tunneled through Arc Gateway (per MS docs) +# These go: Agent -> localhost:40343 (Arc Proxy) -> Enterprise Proxy -> Gateway -> Target +# Direct TCP test to target is NOT meaningful for tunneled endpoints. +$script:GatewayTunneledPatterns = @( + '*.his.arc.azure.com' + '*.guestconfiguration.azure.com' + 'dc.services.visualstudio.com' + 'guestnotificationservice.azure.com' +) $azcm = Get-AzcmagentPath $script:PreOnboarding = (-not $azcm) @@ -273,6 +374,7 @@ if ($script:PreOnboarding) { if ($azcm -and $script:AgentJson) { $agSt = if ($script:AgentJson.PSObject.Properties['status']) { $script:AgentJson.status } else { $null } $agVer = if ($script:AgentJson.PSObject.Properties['agentVersion']) { $script:AgentJson.agentVersion } else { $null } + $script:AgentVersion = $agVer $agParts = @('Installed') if ($agSt) { $agParts += $agSt } if ($agVer) { $agParts += "v$agVer" } @@ -350,31 +452,67 @@ $modeColor = switch ($Mode) { } Write-Status 'Mode' $Mode $modeColor +# Apply -GatewayUrl parameter override (pre-onboarding) +if ($GatewayUrl -and -not $script:GatewayUrl) { + $script:GatewayUrl = $GatewayUrl + if ($Mode -eq 'Auto' -or $Mode -eq 'Public') { $Mode = 'Gateway' } +} + if ($script:GatewayUrl) { Write-Status 'Gateway' $script:GatewayUrl DarkYellow } # --- Installed Extensions (auto-detect) --- +# Strategy: Read from plugin directory first (fast, non-disruptive). +# Fallback to 'azcmagent extension list' only if directory not found. +# Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-vm-extensions#general-troubleshooting +# Path: C:\Packages\Plugins\\\ if (-not $SkipExtensions -and $azcm) { - try { - $extOut = & $azcm extension list 2>$null - if ($extOut) { - $extLines = $extOut | Out-String - if ($extLines -match 'WindowsAgent\.SqlServer|LinuxAgent\.SqlServer|SqlServer') { $script:InstalledExts += 'SQL' } - if ($extLines -match 'AzureMonitor|AMA') { $script:InstalledExts += 'AMA' } - if ($extLines -match 'MDE|DefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } - if ($extLines -match 'AdminCenter') { $script:InstalledExts += 'WAC' } - if ($extLines -match 'KeyVault') { $script:InstalledExts += 'KV' } - if ($extLines -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } - if ($extLines -match 'ChangeTracking') { $script:InstalledExts += 'CT' } - if ($extLines -match 'GuestAttestation|WindowsAttestation|LinuxAttestation') { $script:InstalledExts += 'GA' } - if ($extLines -match 'WindowsPatchExtension|LinuxPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } - if ($extLines -match 'CustomScript') { $script:InstalledExts += 'CS' } - if ($extLines -match 'DependencyAgent') { $script:InstalledExts += 'DA' } - if ($extLines -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } + $pluginDir = Join-Path $env:SystemDrive 'Packages\Plugins' + $detected = $false + + # --- Fast path: scan plugin directory names --- + if (Test-Path $pluginDir) { + $plugins = (Get-ChildItem -Path $pluginDir -Directory -ErrorAction SilentlyContinue).Name -join '|' + if ($plugins) { + $detected = $true + if ($plugins -match 'SqlServer|WindowsAgent\.SqlServer') { $script:InstalledExts += 'SQL' } + if ($plugins -match 'AzureMonitor|AzureMonitorWindowsAgent') { $script:InstalledExts += 'AMA' } + if ($plugins -match 'MDE|AzureDefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } + if ($plugins -match 'AdminCenter') { $script:InstalledExts += 'WAC' } + if ($plugins -match 'KeyVault') { $script:InstalledExts += 'KV' } + if ($plugins -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } + if ($plugins -match 'ChangeTracking') { $script:InstalledExts += 'CT' } + if ($plugins -match 'GuestAttestation|WindowsAttestation') { $script:InstalledExts += 'GA' } + if ($plugins -match 'WindowsPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } + if ($plugins -match 'CustomScript|RunCommand') { $script:InstalledExts += 'CS' } + if ($plugins -match 'DependencyAgent') { $script:InstalledExts += 'DA' } + if ($plugins -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } } } - catch { } + + # --- Fallback: azcmagent extension list (slower, stops Extension Service) --- + if (-not $detected) { + try { + $extOut = & $azcm extension list 2>$null + if ($extOut) { + $extLines = $extOut | Out-String + if ($extLines -match 'WindowsAgent\.SqlServer|SqlServer') { $script:InstalledExts += 'SQL' } + if ($extLines -match 'AzureMonitor|AMA') { $script:InstalledExts += 'AMA' } + if ($extLines -match 'MDE|DefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } + if ($extLines -match 'AdminCenter') { $script:InstalledExts += 'WAC' } + if ($extLines -match 'KeyVault') { $script:InstalledExts += 'KV' } + if ($extLines -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } + if ($extLines -match 'ChangeTracking') { $script:InstalledExts += 'CT' } + if ($extLines -match 'GuestAttestation|WindowsAttestation') { $script:InstalledExts += 'GA' } + if ($extLines -match 'WindowsPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } + if ($extLines -match 'CustomScript|RunCommand') { $script:InstalledExts += 'CS' } + if ($extLines -match 'DependencyAgent') { $script:InstalledExts += 'DA' } + if ($extLines -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } + } + } + catch { } + } } if ($script:InstalledExts.Count -gt 0) { @@ -395,10 +533,10 @@ else { } # ========================================================================= -# 2. PROXY DETECTION +# PHASE 2: PROXY CHAIN (WinHTTP -> Agent -> Env -> Gateway) # ========================================================================= -Write-Section 'Proxy Configuration' +Write-Section 'Proxy Chain (WinHTTP -> Agent -> HTTPS_PROXY -> Gateway)' # --- WinHTTP --- try { @@ -482,6 +620,30 @@ if ($script:EffectiveProxy) { Write-Status 'Effective proxy' $script:EffectiveProxy Green } +# --- Proxy flow explanation (shown when proxy is configured or PLS/Gateway active) --- +if ($script:EffectiveProxy -or $script:WinHttpProxy -or $Mode -in 'Private', 'Gateway') { + Write-Host '' + Write-Host ' Traffic flow per component:' -ForegroundColor DarkGray + if ($Mode -eq 'Gateway') { + Write-Host ' Arc Agent (tunneled) : Agent -> localhost:40343 -> Upstream Proxy -> Gateway -> Target' -ForegroundColor DarkGray + Write-Host ' Arc Agent (direct) : Agent -> Enterprise Proxy -> Target' -ForegroundColor DarkGray + } + elseif ($Mode -eq 'Private') { + Write-Host ' Arc Agent (PLS) : Agent -> Private Endpoint (VNET) -> Target (private IP)' -ForegroundColor DarkGray + Write-Host ' Arc Agent (non-PLS) : Agent -> Proxy (if set) -> Target (public IP)' -ForegroundColor DarkGray + } + else { + Write-Host ' Arc Agent : Agent -> azcmagent proxy.url -> Target' -ForegroundColor DarkGray + } + Write-Host ' Extensions : Extension -> HTTPS_PROXY -> Target' -ForegroundColor DarkGray + Write-Host ' SCHANNEL (PKI/CRL) : OS -> WinHTTP proxy (unless endpoint in bypass) -> Target' -ForegroundColor DarkGray + Write-Host ' TCP test (this script): Direct to Target:443 (no proxy - validates L3/L4)' -ForegroundColor DarkGray + Write-Host ' HTTP test (this scrpt): Via effective proxy (validates L7 app-layer path)' -ForegroundColor DarkGray + if ($Mode -eq 'Private') { + Write-Host ' DNS (Private Link) : PLS endpoints resolve to private IP (validated in DNS test)' -ForegroundColor DarkGray + } +} + # --- Gateway + proxy.bypass warning --- if ($Mode -eq 'Gateway' -and $agentBypass) { Add-Issue -Sev 'WARN' -Cat 'Gateway' ` @@ -497,10 +659,10 @@ if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { Log "Region=$Region Mode=$Mode Proxy=$($script:EffectiveProxy) Gateway=$($script:GatewayUrl)" Info -NoCount # ========================================================================= -# 3. TLS VERSION CHECK +# PHASE 3: TLS & CRYPTO VALIDATION # ========================================================================= -Write-Section 'TLS Validation' +Write-Section 'TLS & Crypto Validation' # Azure Arc requires TLS 1.2 or 1.3 ONLY. # Required cipher suites: # TLS 1.3: TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256 @@ -543,9 +705,9 @@ try { # --- 2. Real TLS 1.2 Handshake Test --- $tlsHandshakeOk = $false $negotiatedProto = '' + $savedProto = [System.Net.ServicePointManager]::SecurityProtocol try { # Force .NET to use TLS 1.2 for this test - $savedProto = [System.Net.ServicePointManager]::SecurityProtocol [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 $testReq = [System.Net.HttpWebRequest]::Create('https://login.microsoftonline.com') $testReq.Timeout = 10000 @@ -560,12 +722,13 @@ try { $testResp.Close() $tlsHandshakeOk = $true $negotiatedProto = 'TLS 1.2' - [System.Net.ServicePointManager]::SecurityProtocol = $savedProto } catch { - try { [System.Net.ServicePointManager]::SecurityProtocol = $savedProto } catch { } # If TLS 1.2 fails, the OS may not support it } + finally { + [System.Net.ServicePointManager]::SecurityProtocol = $savedProto + } # --- 3. Cipher Suite Check --- $requiredCiphers12 = @( @@ -594,12 +757,15 @@ try { # --- 4. .NET Strong Crypto --- $strongCrypto = $false + $sysDefaultTls = $false $regPath64 = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' $regPath32 = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' foreach ($rp in @($regPath64, $regPath32)) { if (Test-Path $rp) { $sc = (Get-ItemProperty -Path $rp -Name 'SchUseStrongCrypto' -EA SilentlyContinue).SchUseStrongCrypto if ($sc -eq 1) { $strongCrypto = $true } + $sd = (Get-ItemProperty -Path $rp -Name 'SystemDefaultTlsVersions' -EA SilentlyContinue).SystemDefaultTlsVersions + if ($sd -eq 1) { $sysDefaultTls = $true } } } @@ -637,12 +803,31 @@ try { Write-Status 'Cipher Suites' 'Required GCM suites present' Green } - # .NET StrongCrypto - if ($strongCrypto) { - Write-Status '.NET StrongCrypto' 'Enabled' Green + # .NET StrongCrypto + SystemDefaultTlsVersions + if ($strongCrypto -and $sysDefaultTls) { + Write-Status '.NET TLS Config' 'SchUseStrongCrypto=1, SystemDefaultTlsVersions=1' Green + } + elseif ($strongCrypto) { + Write-Status '.NET StrongCrypto' 'Enabled (SystemDefaultTlsVersions NOT set)' Yellow + } + elseif ($sysDefaultTls) { + Write-Status '.NET TLS Config' 'SystemDefaultTlsVersions=1 (SchUseStrongCrypto NOT set)' Yellow } else { - Write-Status '.NET StrongCrypto' 'NOT set (recommended for PS 5.1 / .NET apps)' Yellow + # On Server 2016+ (.NET 4.6+), TLS 1.2 is default even without these keys (INFO only) + # On Server 2012 R2, this is a blocking issue (WARN) + $netSev = if ($isModernOS -and $osVer.Major -ge 10) { 'INFO' } else { 'WARN' } + $netMsg = if ($netSev -eq 'INFO') { + '.NET TLS keys not set (OK on this OS - .NET 4.6+ defaults to TLS 1.2)' + } else { + '.NET Framework not configured for TLS 1.2 default. Extensions may fail with: "Could not create SSL/TLS secure channel"' + } + Write-Status '.NET TLS Config' 'Neither StrongCrypto nor SystemDefaultTlsVersions set' $(if ($netSev -eq 'INFO') { 'DarkGray' } else { 'Yellow' }) + if ($netSev -eq 'WARN') { + Add-Issue -Sev 'WARN' -Cat 'TLS .NET' ` + -Msg $netMsg ` + -Fix 'Set SchUseStrongCrypto=1 and SystemDefaultTlsVersions=1 in HKLM:\SOFTWARE\[Wow6432Node\]Microsoft\.NETFramework\v4.0.30319' + } } # Server 2012 (non-R2) + SQL Arc warning @@ -657,10 +842,93 @@ catch { Write-Status 'TLS' "Check error: $($_.Exception.Message)" Yellow $tlsOk = $true } -Log "TLS check: OK=$tlsOk handshake=$tlsHandshakeOk ciphers=$cipherOk strongCrypto=$strongCrypto" $(if ($tlsOk) { 'OK' } else { 'Fail' }) +Log "TLS check: OK=$tlsOk handshake=$tlsHandshakeOk ciphers=$cipherOk strongCrypto=$strongCrypto sysDefaultTls=$sysDefaultTls" $(if ($tlsOk) { 'OK' } else { 'Fail' }) + +# --- TLS Registry Dump (full diagnostic view) --- +Write-Section 'TLS Registry Dump (Actual vs Recommended)' + +$tlsRegChecks = @( + @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'; Name = 'Enabled'; Recommended = 1; Scope = 'TLS Client'; UsedBy = 'Arc Agent, OCSP/CRL' } + @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'; Name = 'DisabledByDefault'; Recommended = 0; Scope = 'TLS Client'; UsedBy = 'Arc Agent, OCSP/CRL' } + @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'; Name = 'Enabled'; Recommended = 1; Scope = 'TLS Server'; UsedBy = 'WAC inbound, RDP' } + @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'; Name = 'DisabledByDefault'; Recommended = 0; Scope = 'TLS Server'; UsedBy = 'WAC inbound, RDP' } + @{ Path = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'; Name = 'SchUseStrongCrypto'; Recommended = 1; Scope = '.NET x64'; UsedBy = 'PS 5.1, Extensions' } + @{ Path = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'; Name = 'SystemDefaultTlsVersions'; Recommended = 1; Scope = '.NET x64'; UsedBy = 'PS 5.1, Extensions' } + @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'; Name = 'SchUseStrongCrypto'; Recommended = 1; Scope = '.NET x86'; UsedBy = '32-bit .NET apps' } + @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'; Name = 'SystemDefaultTlsVersions'; Recommended = 1; Scope = '.NET x86'; UsedBy = '32-bit .NET apps' } +) + +$rdFmt = " {0,-6} | {1,-10} | {2,-24} | {3,-9} | {4,-5} | {5}" +Write-Host ($rdFmt -f 'Status', 'Scope', 'Name', 'Value', 'Rec.', 'Used By') -ForegroundColor Cyan +Write-Host (" {0,-6}-+-{1,-10}-+-{2,-24}-+-{3,-9}-+-{4,-5}-+-{5}" -f ('-' * 6), ('-' * 10), ('-' * 24), ('-' * 9), ('-' * 5), ('-' * 22)) -ForegroundColor DarkGray + +$tlsRegIssues = 0 +foreach ($chk in $tlsRegChecks) { + $val = $null + $valStr = 'N/A' + if (Test-Path $chk.Path) { + $prop = Get-ItemProperty -Path $chk.Path -Name $chk.Name -ErrorAction SilentlyContinue + if ($null -ne $prop -and $null -ne $prop.($chk.Name)) { + $val = $prop.($chk.Name) + $valStr = "$val" + } + else { + $valStr = '(not set)' + } + } + else { + $valStr = '(key missing)' + } + + # Determine status + $recStr = "$($chk.Recommended)" + if ($val -eq $chk.Recommended) { + $status = 'OK' + $color = 'Green' + } + elseif ($null -eq $val -and $chk.Scope -like 'TLS *') { + # SCHANNEL keys not set = OS default (TLS 1.2 enabled on 2012R2+ with KB, 2016+ native) + $status = 'DFLT' + $color = 'DarkYellow' + } + else { + $status = 'WARN' + $color = 'Yellow' + $tlsRegIssues++ + } + + # Shorten path for display + $shortPath = $chk.Path -replace 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\SecurityProviders\\SCHANNEL\\Protocols\\', '...\SCHANNEL\' ` + -replace 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\', '...\WOW6432Node\' ` + -replace 'HKLM:\\SOFTWARE\\Microsoft\\', '...\Microsoft\' + + $usedBy = if ($chk.UsedBy) { $chk.UsedBy } else { '-' } + Write-Host ($rdFmt -f $status, $chk.Scope, $chk.Name, $valStr, $recStr, $usedBy) -ForegroundColor $color +} + +if ($tlsRegIssues -gt 0) { + Write-Host '' + Write-Host " $tlsRegIssues registry value(s) not matching recommendation." -ForegroundColor Yellow + Write-Host ' Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' -ForegroundColor DarkCyan + Write-Host ' Ref: https://learn.microsoft.com/entra/identity/hybrid/connect/reference-connect-tls-enforcement' -ForegroundColor DarkCyan +} +else { + Write-Host '' + Write-Host ' All TLS registry values match Azure Arc recommendations.' -ForegroundColor Green +} +Write-Host '' +Write-Host ' Legend: OK=value matches | DFLT=not set but OS default is correct (Server 2016+) | WARN=should be configured' -ForegroundColor DarkGray + +# Also log to file +$tlsRegLog = $tlsRegChecks | ForEach-Object { + $v = $null + if (Test-Path $_.Path) { $v = (Get-ItemProperty -Path $_.Path -Name $_.Name -EA SilentlyContinue).($_.Name) } + "$($_.Scope) | $($_.Name) = $(if ($null -ne $v) { $v } else { 'N/A' }) (rec: $($_.Recommended))" +} +Log "TLS Registry: $($tlsRegLog -join ' ; ')" Info -NoCount # ========================================================================= -# 4. PKI/OCSP/CRL BYPASS VALIDATION +# PHASE 4: PKI/OCSP/CRL BYPASS VALIDATION # ========================================================================= $pkiEndpoints = @( @@ -671,7 +939,7 @@ $pkiEndpoints = @( 'crl4.digicert.com' # CRL DigiCert alt 'ocsp.digicert.com' # OCSP DigiCert 'ctldl.windowsupdate.com' # Certificate Trust List - 'www.microsoft.com' # PKI AIA chain + 'www.microsoft.com' # PKI AIA chain + /pkiops/certs (ESU HTTP:80+HTTPS:443) 'caissuers.microsoft.com' # CA Issuers (AIA) 'login.live.com' # Live ID cert validation ) @@ -746,7 +1014,7 @@ if (-not $SkipPKI -and $script:WinHttpProxy) { } # ========================================================================= -# 5. ENDPOINT DEFINITIONS +# PHASE 5: ENDPOINT DEFINITIONS # ========================================================================= # Reset stats for test phase @@ -763,19 +1031,28 @@ $canBePrivate = [System.Collections.ArrayList]@( ) # --- Core endpoints (always tested) --- +# Ref: https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated $coreEps = [System.Collections.ArrayList]@( - 'login.windows.net' - 'login.microsoftonline.com' - "$Region.login.microsoft.com" - 'pas.windows.net' - 'management.azure.com' - 'gbl.his.arc.azure.com' - 'agentserviceapi.guestconfiguration.azure.com' - 'packages.microsoft.com' - 'download.microsoft.com' - 'dc.services.visualstudio.com' + 'login.windows.net' # AAD authentication + 'login.microsoftonline.com' # AAD authentication + "$Region.login.microsoft.com" # AAD regional + 'pas.windows.net' # AAD token (access packages) + 'management.azure.com' # ARM (Azure Resource Manager) + 'gbl.his.arc.azure.com' # Arc Hybrid Identity Service (global) + 'agentserviceapi.guestconfiguration.azure.com' # Guest Configuration (global) + 'packages.microsoft.com' # Agent/extension packages (Linux apt/yum) + 'download.microsoft.com' # Agent installer + extension downloads + 'mcr.microsoft.com' # Extension container images ) +# dc.services.visualstudio.com — not used in agent 1.24+ (replaced by ARM telemetry) +$agVerParts = if ($script:AgentVersion) { $script:AgentVersion -split '\.' } else { @() } +$agMajor = if ($agVerParts.Count -ge 1) { try { [int]$agVerParts[0] } catch { 0 } } else { 0 } +$agMinor = if ($agVerParts.Count -ge 2) { try { [int]$agVerParts[1] } catch { 0 } } else { 0 } +if ($script:PreOnboarding -or ($agMajor -lt 1) -or ($agMajor -eq 1 -and $agMinor -lt 24)) { + [void]$coreEps.Add('dc.services.visualstudio.com') +} + # GNS global (Public/Gateway modes) if ($Mode -in 'Public', 'Gateway') { [void]$coreEps.Add('guestnotificationservice.azure.com') @@ -796,38 +1073,50 @@ if ($script:GatewayUrl) { # --- Extension endpoints (auto-detected) --- $extEps = @{} -# SQL Server +# SQL Server enabled by Azure Arc +# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint +# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/prerequisites if ($script:InstalledExts -contains 'SQL' -or $CheckIncludeAll) { $extEps['SQL'] = @( - "dataprocessingservice.$Region.arcdataservices.com" - "telemetry.$Region.arcdataservices.com" - "san-af-$Region-prod.azurewebsites.net" - 'graph.microsoft.com' + "dataprocessingservice.$Region.arcdataservices.com" # Data processing (telemetry upload) + "telemetry.$Region.arcdataservices.com" # Telemetry collection + "san-af-$Region-prod.azurewebsites.net" # SQL Assessment (legacy, may be deprecated) + 'graph.microsoft.com' # AAD Graph for SQL auth ) } # Defender for SQL (separate from MDE) +# Ref: https://learn.microsoft.com/azure/defender-for-cloud/defender-for-sql-usage if ($script:InstalledExts -contains 'DSQL' -or $CheckIncludeAll) { if (-not $extEps.ContainsKey('SQL')) { $extEps['SQL'] = @() } $extEps['SQL'] += @("defender-for-databases.$Region.arcdataservices.com") } # AMA (Azure Monitor Agent) + Dependency Agent +# Ref: https://learn.microsoft.com/azure/azure-monitor/agents/azure-monitor-agent-network-configuration if ($script:InstalledExts -contains 'AMA' -or $script:InstalledExts -contains 'DA' -or $CheckIncludeAll) { $extEps['AMA'] = @( - 'global.handler.control.monitor.azure.com' - 'global.prod.microsoftmetrics.com' - "$Region.handler.control.monitor.azure.com" - "$Region.monitoring.azure.com" + 'global.handler.control.monitor.azure.com' # Agent control channel (global) + "$Region.handler.control.monitor.azure.com" # Agent control channel (regional) + "$Region.monitoring.azure.com" # Metrics ingestion (DCR) + 'global.prod.microsoftmetrics.com' # Metrics publishing ) + # NOTE: Log ingestion uses ..ingest.monitor.azure.com + # which is customer-specific (Data Collection Endpoint). Not testable without DCE context. } # MDE (Microsoft Defender for Endpoint) +# Ref: https://learn.microsoft.com/defender-endpoint/configure-proxy-internet +# Ref: https://learn.microsoft.com/defender-endpoint/configure-environment#streamlined-connectivity +# NOTE: MDE endpoints vary by tenant geo (US/EU/UK). Below are US defaults. +# If tenant is in EU/UK, these will differ. Agent geo is detected from onboarding blob. if ($script:InstalledExts -contains 'MDE' -or $CheckIncludeAll) { $extEps['MDE'] = @( - 'unitedstates.x.cp.wd.microsoft.com' - 'us-v20.events.data.microsoft.com' - 'winatp-gw-cus3.microsoft.com' + 'unitedstates.x.cp.wd.microsoft.com' # Cyber data (US geo) + 'us-v20.events.data.microsoft.com' # EDR telemetry (US geo) + 'winatp-gw-cus3.microsoft.com' # Gateway (Central US) + 'go.microsoft.com' # MDE update/CnC channel + '*.endpoint.security.microsoft.com' # Unified MDE endpoint (streamlined) ) } @@ -842,10 +1131,12 @@ if ($script:InstalledExts -contains 'KV' -or $CheckIncludeAll) { } # Hybrid Runbook Worker +# Ref: https://learn.microsoft.com/azure/automation/automation-hybrid-runbook-worker#network-planning if ($script:InstalledExts -contains 'HRW' -or $CheckIncludeAll) { $extEps['HRW'] = @( - '*.azure-automation.net' - '*.agentsvc.azure-automation.net' + '*.azure-automation.net' # Automation account + '*.agentsvc.azure-automation.net' # Agent service + '*.jrds.azure-automation.net' # Job Runtime Data Service ) } @@ -859,6 +1150,18 @@ if ($script:InstalledExts -contains 'GA' -or $CheckIncludeAll) { $extEps['GA'] = @('*.attest.azure.net') } +# --- Extension download infrastructure (wildcard, always needed if any extension) --- +# Ref: https://learn.microsoft.com/azure/azure-arc/servers/network-requirements +# These are wildcards — cannot be TCP-tested but must be in firewall allow list +$extDownloadWildcards = @( + '*.blob.core.windows.net' # Extension package download (Azure Storage) + '*.dl.delivery.mp.microsoft.com' # Extension package download (CDN alt) + '*.data.mcr.microsoft.com' # Container image layers (MCR) + '*.servicebus.windows.net' # GNS notification channel (Public mode) + '*.ods.opinsights.azure.com' # Log Analytics data ingestion (AMA/MMA) + '*.oms.opinsights.azure.com' # Log Analytics management (AMA/MMA) +) + # -SkipExtensions overrides -CheckIncludeAll (user explicitly asked to skip) if ($SkipExtensions -and $extEps.Count -gt 0) { $extEps = @{} @@ -866,7 +1169,7 @@ if ($SkipExtensions -and $extEps.Count -gt 0) { } # ========================================================================= -# 6. DISCOVER REGIONAL ENDPOINTS (azcmagent check) +# PHASE 6: ENDPOINT DISCOVERY (azcmagent check) # ========================================================================= # Regional Arc endpoints use unpredictable abbreviations (e.g. eus2, brs, ncus). # Instead of guessing, we parse 'azcmagent check' output to discover the actual @@ -1006,6 +1309,10 @@ if (-not $SkipPKI) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'PKI' } } } +# Extension download infrastructure wildcards +foreach ($ep in $extDownloadWildcards) { + if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'DL' } +} # --- Dynamic GNS allowlist (Public mode only) --- $dynamicEps = @() @@ -1049,6 +1356,9 @@ foreach ($grp in $extEps.Keys) { $allTestable += $dynamicEps if (-not $SkipPKI) { $allTestable += $pkiEndpoints } +# Add extension download wildcards (always needed) +$allTestable += $extDownloadWildcards + # Separate wildcards (informational, not testable) from concrete FQDNs $wildcardEps = @($allTestable | Where-Object { $_ -match '^\*\.' } | Select-Object -Unique) $allTestable = @($allTestable | Where-Object { $_ -notmatch '^\*\.' } | Where-Object { $_ } | Select-Object -Unique) @@ -1059,6 +1369,10 @@ if ($extEps.ContainsKey('SQL')) { $httpProbeEps += "dataprocessingservice.$Region.arcdataservices.com" $httpProbeEps += "telemetry.$Region.arcdataservices.com" } +# Probe Gateway URL if configured +if ($script:GatewayUrl) { + $httpProbeEps += $script:GatewayUrl +} # --- Agent proxy.bypass => skip HTTP for bypassed endpoints --- $httpBypassedEps = [System.Collections.ArrayList]::new() @@ -1092,10 +1406,28 @@ if ($azcm -and $script:EffectiveProxy) { } # ========================================================================= -# 7. ENDPOINT TESTS: DNS + TCP/443 +# PHASE 7: CONNECTIVITY TESTS (DNS + TCP + HTTP) # ========================================================================= -Write-Banner "TESTING $($allTestable.Count) ENDPOINTS" +Write-Banner "PHASE 2: CONNECTIVITY TESTS ($($allTestable.Count) endpoints)" + +# Gateway mode: verify Arc Proxy is listening on localhost:40343 +if ($Mode -eq 'Gateway' -and -not $script:PreOnboarding) { + Write-Section 'Arc Gateway Proxy (localhost:40343)' + $gwProxyOk = Test-TcpPort -H '127.0.0.1' -P 40343 -T 3000 + if ($gwProxyOk) { + Write-Status 'Arc Proxy' 'localhost:40343 reachable' Green + Log 'Arc Gateway Proxy localhost:40343 reachable' OK + } + else { + Write-Status 'Arc Proxy' 'localhost:40343 NOT reachable' Red + Log 'Arc Gateway Proxy localhost:40343 NOT reachable' Fail + Add-Issue -Sev 'CRITICAL' -Cat 'Gateway' ` + -Msg 'Arc Gateway local proxy (localhost:40343) not responding. Tunneled endpoints will fail.' ` + -Fix 'Verify Arc Gateway is properly configured: azcmagent config get proxy.url' + } + Write-Host '' +} $pi = 0 foreach ($ep in $allTestable) { @@ -1104,7 +1436,8 @@ foreach ($ep in $allTestable) { $pi++ $grp = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Core' } - Add-Result -Endpoint $ep -Group $grp + $epPath = if (Test-IsGatewayTunneled -Endpoint $ep) { 'Tunnel' } else { 'Direct' } + Add-Result -Endpoint $ep -Group $grp -Path $epPath $pct = [math]::Round(($pi / $allTestable.Count) * 100) $epShort = if ($ep.Length -gt 56) { $ep.Substring(0, 53) + '...' } else { $ep } @@ -1159,28 +1492,43 @@ foreach ($ep in $allTestable) { } # --- TCP/443 --- - # Note: TCP tests L3/L4 reachability directly (not via proxy). - # In explicit proxy setups, this validates the network path through the firewall. - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $ok = Test-TcpPort -H $ep -P 443 -T 5000 - $sw.Stop() - $ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 0) - - $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($ok) { - Log "TCP OK ${ep}:443 (${ms}ms)" OK - if ($rr3) { $rr3.TCP = 'OK'; $rr3.Latency = "${ms}ms" } + # In Gateway mode, tunneled endpoints route through localhost:40343 (Arc Proxy) + # so direct TCP to the target IP is NOT expected to work. + $isTunneled = Test-IsGatewayTunneled -Endpoint $ep + if ($isTunneled) { + Log "TCP SKIP ${ep}:443 (tunneled via Gateway)" Info -NoCount + $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($rr3) { $rr3.TCP = 'TUNNEL'; $rr3.Latency = 'n/a' } } else { - Log "TCP FAIL ${ep}:443" Fail - if ($rr3) { $rr3.TCP = 'FAIL'; $rr3.Latency = 'timeout' } - Add-Issue -Sev 'HIGH' -Cat 'TCP' -Msg "Cannot connect to ${ep}:443" -Fix 'Check firewall/proxy rules' + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $ok = Test-TcpPort -H $ep -P 443 -T 5000 + $sw.Stop() + $ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 0) + + $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } + if ($ok) { + Log "TCP OK ${ep}:443 (${ms}ms)" OK + if ($rr3) { $rr3.TCP = 'OK'; $rr3.Latency = "${ms}ms" } + } + else { + # In proxy scenarios, TCP fail may be expected (proxy handles L7) + if ($script:EffectiveProxy -and $grp -ne 'PKI') { + Log "TCP WARN ${ep}:443 (proxy may handle)" Warn + if ($rr3) { $rr3.TCP = 'WARN'; $rr3.Latency = 'proxy' } + } + else { + Log "TCP FAIL ${ep}:443" Fail + if ($rr3) { $rr3.TCP = 'FAIL'; $rr3.Latency = 'timeout' } + Add-Issue -Sev 'HIGH' -Cat 'TCP' -Msg "Cannot connect to ${ep}:443" -Fix 'Check firewall/proxy rules' + } + } } } Write-Host '' # Clear progress line # ========================================================================= -# 8. HTTP TESTS + PKI PROBE +# PHASE 7b: HTTP TESTS + PKI PROBE # ========================================================================= foreach ($ep in $httpProbeEps) { @@ -1201,8 +1549,9 @@ foreach ($ep in $httpProbeEps) { if ($_.Exception.Response) { try { $code = [int]$_.Exception.Response.StatusCode } catch { } } - if ($code -in 400, 401, 403, 404) { - Log "HTTP OK $ep -> $code" OK + # Any HTTP response (even 4xx/5xx) means network + TLS worked + if ($code -and $code -ge 100 -and $code -lt 600) { + Log "HTTP OK $ep -> $code (endpoint reachable)" OK Add-Result -Endpoint $ep -HTTP "OK($code)" } else { @@ -1216,6 +1565,79 @@ foreach ($ep in $httpProbeEps) { # PKI HTTP probe — tests the REAL path SCHANNEL will use: # If oneocsp.microsoft.com is in bypass → test DIRECT (no proxy) # If NOT in bypass → test via WinHTTP proxy (likely fails on explicit proxy) + +# --- SQL Arc TLS 1.2 probe --- +# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint#check-tls-version-compatibility +# arcdataservices.com REQUIRES TLS 1.2+ and GCM ciphers. Server 2012 (non-R2) will fail here. +if ($extEps.ContainsKey('SQL')) { + $sqlTlsTarget = "dataprocessingservice.$Region.arcdataservices.com" + $sqlTlsOk = $false + $savedSqlProto = [Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $sqlReq = [System.Net.HttpWebRequest]::Create("https://$sqlTlsTarget") + $sqlReq.Timeout = 10000 + $sqlReq.Method = 'HEAD' + if ($script:EffectiveProxy) { + $sqlReq.Proxy = [System.Net.WebProxy]::new($script:EffectiveProxy) + $sqlReq.Proxy.UseDefaultCredentials = $true + } elseif ($PSVersionTable.PSVersion.Major -lt 6) { + $sqlReq.Proxy = $null + } + $sqlResp = $sqlReq.GetResponse() + $sqlResp.Close() + $sqlTlsOk = $true + } + catch { + $sqlErr = $_.Exception.Message + # In PS 5.1, .GetResponse() wraps WebException in MethodInvocationException + $innerEx = if ($_.Exception.InnerException) { $_.Exception.InnerException } else { $_.Exception } + # If we got an HTTP response (4xx, 5xx), TLS handshake succeeded + if ($innerEx -is [System.Net.WebException]) { + $wex = [System.Net.WebException]$innerEx + if ($wex.Response) { + # Any HTTP response means TLS worked (endpoint just doesn't accept GET/HEAD) + $sqlTlsOk = $true + } + elseif ($wex.Status -eq [System.Net.WebExceptionStatus]::SecureChannelFailure -or + $wex.Status -eq [System.Net.WebExceptionStatus]::TrustFailure) { + # Explicit TLS failure + $sqlTlsOk = $false + } + elseif ($wex.Status -eq [System.Net.WebExceptionStatus]::ConnectFailure -or + $wex.Status -eq [System.Net.WebExceptionStatus]::Timeout) { + # Network issue, not TLS-specific + $sqlTlsOk = $false + $sqlErr = "Network: $($wex.Status) - $sqlErr" + } + else { + # Other WebException — connection was established (TLS likely OK) + # ReceiveFailure, ProtocolError without Response, etc. + $sqlTlsOk = $true + } + } + elseif ($_.Exception.Response -or ($innerEx -and $innerEx.Response)) { + # Got HTTP response through another exception wrapper + $sqlTlsOk = $true + } + } + finally { + [Net.ServicePointManager]::SecurityProtocol = $savedSqlProto + } + + if ($sqlTlsOk) { + Log "SQL TLS probe OK: $sqlTlsTarget (TLS 1.2 handshake succeeded)" OK + Add-Result -Endpoint $sqlTlsTarget -HTTP 'OK(TLS)' + } + else { + Log "SQL TLS probe FAIL: $sqlTlsTarget - $sqlErr" Fail + Add-Result -Endpoint $sqlTlsTarget -HTTP 'FAIL(TLS)' + Add-Issue -Sev 'HIGH' -Cat 'SQL TLS' ` + -Msg "TLS 1.2 handshake failed to $sqlTlsTarget. SQL Arc telemetry will not work." ` + -Fix 'Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint#check-tls-version-compatibility' + } +} + if (-not $SkipPKI -and $script:WinHttpProxy) { $uncPkiNow = Test-PkiBypassCoverage $ocspBypassed = $uncPkiNow -notcontains 'oneocsp.microsoft.com' @@ -1301,10 +1723,10 @@ elseif (-not $azcm) { Save-Log # ========================================================================= -# 9. RESULTS TABLE +# PHASE 8: RESULTS # ========================================================================= -Write-Banner 'RESULTS' +Write-Banner 'PHASE 3: RESULTS' $tbl = $script:Results | ForEach-Object { [pscustomobject]$_ } @@ -1318,10 +1740,10 @@ $grps = $tbl | Group-Object Group | Sort-Object @{ Expression = { } } # Pipe-delimited table (azcmagent check style) -$hf = " {0,-5} | {1,-50} | {2,-16} | {3,-4} | {4,-9} | {5,-7}" -Write-Host ($hf -f 'Group', 'Endpoint', 'IP', 'Type', 'Result', 'Latency') -ForegroundColor Cyan -Write-Host (" {0,-5}-+-{1,-50}-+-{2,-16}-+-{3,-4}-+-{4,-9}-+-{5,-7}" -f ` - ('-' * 5), ('-' * 50), ('-' * 16), ('-' * 4), ('-' * 9), ('-' * 7)) -ForegroundColor DarkGray +$hf = " {0,-5} | {1,-46} | {2,-16} | {3,-4} | {4,-6} | {5,-9} | {6,-7}" +Write-Host ($hf -f 'Group', 'Endpoint', 'IP', 'Type', 'Path', 'Result', 'Latency') -ForegroundColor Cyan +Write-Host (" {0,-5}-+-{1,-46}-+-{2,-16}-+-{3,-4}-+-{4,-6}-+-{5,-9}-+-{6,-7}" -f ` + ('-' * 5), ('-' * 46), ('-' * 16), ('-' * 4), ('-' * 6), ('-' * 9), ('-' * 7)) -ForegroundColor DarkGray foreach ($g in $grps) { foreach ($r in $g.Group) { @@ -1329,7 +1751,7 @@ foreach ($g in $grps) { $tcpOk = $r.TCP -notin @('FAIL', '-') $httpOk = ($r.HTTP -eq '-') -or ($r.HTTP -like 'OK*') -or ($r.HTTP -eq 'SKIP') $fail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') - $warn = ($r.DNS -eq 'WARN') -or ($r.HTTP -like 'WARN*') + $warn = ($r.DNS -eq 'WARN') -or ($r.TCP -eq 'WARN') -or ($r.HTTP -like 'WARN*') # Compose result column (mimics azcmagent: Reachable / Unreachable / Warning) if ($fail) { @@ -1342,6 +1764,9 @@ foreach ($g in $grps) { elseif ($warn) { $result = 'Warning' } + elseif ($r.TCP -eq 'TUNNEL') { + $result = 'Tunneled' + } elseif ($r.HTTP -eq 'SKIP') { $result = 'Reachable*' } @@ -1349,8 +1774,9 @@ foreach ($g in $grps) { $result = 'Reachable' } - $c = if ($fail) { 'Red' } elseif ($warn) { 'Yellow' } else { 'Green' } - Write-Host ($hf -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $result, $r.Latency) -ForegroundColor $c + $c = if ($fail) { 'Red' } elseif ($warn) { 'Yellow' } elseif ($r.TCP -eq 'TUNNEL') { 'DarkYellow' } else { 'Green' } + $pathCol = if ($r.Path) { $r.Path } else { '-' } + Write-Host ($hf -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $pathCol, $result, $r.Latency) -ForegroundColor $c } } @@ -1367,7 +1793,7 @@ if ($wildcardEps.Count -gt 0) { } # ========================================================================= -# 10. ISSUES +# PHASE 9: ISSUES # ========================================================================= if ($script:Issues.Count -gt 0) { @@ -1391,7 +1817,7 @@ if ($script:Issues.Count -gt 0) { } # ========================================================================= -# 11. FINAL SUMMARY +# PHASE 10: FINAL SUMMARY # ========================================================================= Write-Host '' From c283a0733a00a57866e06de92251de08562ccc1c Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:29:52 -0300 Subject: [PATCH 09/10] Update Azure Arc Connectivity Check documentation Revise Azure Arc Connectivity Check documentation to enhance clarity and detail on validation processes, connectivity modes, and script usage. --- .../arc_endpoint_check/_index.md | 347 ++++++------------ 1 file changed, 112 insertions(+), 235 deletions(-) diff --git a/script_automation/arc_endpoint_check/_index.md b/script_automation/arc_endpoint_check/_index.md index 184ddebb..ec1e1278 100644 --- a/script_automation/arc_endpoint_check/_index.md +++ b/script_automation/arc_endpoint_check/_index.md @@ -4,266 +4,143 @@ title: "Azure Arc Connectivity Check" linkTitle: "Azure Arc Connectivity Check" weight: 1 description: > - Validate Azure Arc agent connectivity, TLS configuration, proxy bypass, and - extension endpoints across Public, Private Link, and Gateway deployments — - with full auto-detection and pre-onboarding support. + Validate Azure Arc connectivity, proxy behavior, agent local health, DNS, TCP, + HTTP, TLS, PKI/OCSP/CRL reachability, and platform-specific metadata paths for + Public, Private, and Gateway scenarios. --- ## Overview -This script validates network connectivity for Azure Arc-enabled servers across all -three connectivity modes: **Public**, **Private Link**, and **Gateway**. It auto-detects -region, connectivity mode, proxy configuration, installed extensions, and regional -endpoints — run it with **zero parameters** on any machine where the agent is installed. +This PowerShell script validates Azure Arc connectivity with emphasis on practical +interpretation of real-world network paths, especially in environments that use: -For **pre-onboarding** (agent not yet installed), pass `-Region`, `-Mode`, and -`-CheckIncludeAll` to validate the network before deploying. +- direct outbound access +- explicit proxy +- Private Link +- Gateway +- split-network designs -### What it validates +It inspects local Azure Arc agent state, tests required and optional endpoints, +checks TLS posture, reviews PKI/OCSP/CRL reachability, and classifies findings as +blocking failures or non-blocking warnings. + +When the Azure Connected Machine agent is installed, the script auto-detects most +settings from the local host. When the agent is not installed, the script still works +in pre-onboarding mode for DNS/TCP/HTTP/TLS validation. + +## What the script validates | Area | Details | | ---- | ------- | -| **Core endpoints** | AAD, ARM, HIMDS, GuestConfig, GNS, packages, downloads | -| **Regional endpoints** | Discovered via `azcmagent check` or DNS-based abbreviation map (40+ regions) | -| **Extension endpoints** | SQL, AMA, MDE, WAC, Key Vault, Hybrid Worker, Change Tracking, Update Manager, Guest Attestation, Dependency Agent, Defender for SQL — auto-detected from installed extensions | -| **DNS + TCP/443** | Resolution, private vs public IP classification, latency measurement | -| **HTTP probes** | Layer-7 reachability through proxy for key endpoints | -| **TLS 1.2/1.3** | SCHANNEL registry, live handshake test, cipher suite validation (GCM), .NET StrongCrypto | -| **PKI/OCSP/CRL bypass** | Detects missing proxy bypass for certificate validation endpoints (Azure Firewall explicit proxy "non-proxy request on proxy port" scenario) | -| **Proxy configuration** | WinHTTP, `azcmagent proxy.url`, `HTTPS_PROXY`, `proxy.bypass` categories, upstream proxy (Gateway) | -| **Dynamic GNS allowlist** | Queries `guestnotificationservice.azure.com` for region-specific ServiceBus endpoints (Public mode) | -| **Arc Gateway** | Validates gateway URL, detects `proxy.bypass` misconfiguration in Gateway mode | - -### Key improvements over earlier versions - -- **Zero parameters needed** — auto-detects everything from `azcmagent show -j`, - `azcmagent check`, `azcmagent extension list`, WinHTTP, and environment variables. -- **Three connectivity modes** — Public, Private Link, and Gateway (with gateway URL - validation and upstream proxy display). -- **Pre-onboarding mode** — works without `azcmagent` installed; uses DNS-based regional - endpoint discovery with a built-in abbreviation map for 40+ Azure regions. -- **PKI bypass validation** — detects when WinHTTP proxy is configured but PKI/OCSP/CRL - endpoints are missing from the bypass list (root cause of Azure Firewall explicit proxy - TLS failures). -- **TLS validation** — SCHANNEL registry check, live TLS 1.2 handshake, cipher suite - verification, .NET StrongCrypto, and Server 2012 (non-R2) SQL Arc incompatibility - warning. -- **Extension auto-detection** — discovers 12 extension types from `azcmagent extension list` - and tests their specific endpoints. -- **Pipe-delimited output** — `azcmagent check` style tabular format with consolidated - `Result` column (Reachable / FAIL(DNS,TCP) / Warning). -- **Smart exit codes** — `0` = PASS or WARN-only, `1` = FAIL (CRITICAL/HIGH issues). - WARN-level issues (e.g., Gateway bypass, Discovery) do not cause exit 1. -- **Locale-independent** WinHTTP proxy detection with URL-based fallback for non-EN/PT - systems. +| **Agent state** | `azcmagent show -j`, local config, service status, version, and `himds.log` tail | +| **Connectivity mode** | `Public`, `Private`, `Gateway`, or `Auto` | +| **Platform context** | `Arc`, `AzureLocal`, `AzureStackHub`, or `AzureVM` | +| **Core Arc endpoints** | Arc runtime, Guest Configuration, AAD, ARM, and lifecycle endpoints | +| **Extension endpoints** | SQL, AMA, MDE, WAC, Key Vault, Hybrid Worker, Update Manager, Guest Attestation, Dependency Agent, Defender for SQL, and others when detected | +| **DNS and TCP** | Name resolution, public/private IP classification, and TCP 443 reachability | +| **HTTP probes** | Layer-7 validation for selected endpoints, including proxy-path interpretation | +| **TLS** | SCHANNEL TLS 1.2 posture, live TLS 1.2 handshake, .NET StrongCrypto, and local cipher inventory when available | +| **PKI / OCSP / CRL** | Reachability and bypass coverage validation for revocation and certificate chain endpoints | +| **Proxy configuration** | WinHTTP, `azcmagent` proxy settings, `HTTPS_PROXY`, bypass categories, and effective proxy path | +| **Gateway behavior** | Gateway URL detection and warning when `proxy.bypass` is configured in Gateway mode | -## Prerequisites +## Key behavior in this revised version -- **Windows PowerShell 5.1 or later** (the script uses `netsh`, `Resolve-DnsName`, and - `azcmagent.exe`). -- Outbound network connectivity to Azure Arc endpoints (directly, via proxy, or via - Gateway). -- *(Optional)* The **Azure Connected Machine agent** (`azcmagent.exe`). Without it the - script runs in pre-onboarding mode — DNS/TCP/HTTP/TLS tests still execute, but - `azcmagent check` and extension auto-detection are skipped. -- Run from an **elevated PowerShell** session for the most complete results. +This version was adjusted to better reflect how Azure Arc behaves in segmented +network designs and to avoid false outage conclusions. -## Getting Started +### Important interpretation for `Private` mode -Download [arcendpointcheck.ps1](./arcendpointcheck.ps1) and run it on the server where -the Azure Arc agent is (or will be) installed. +In `Private` mode, a failed path to `management.azure.com` does **not** automatically +mean Azure Arc runtime is broken. -**You never edit the script.** Everything is controlled by parameters: +Azure Arc Private Link Scope does **not** carry Microsoft Entra ID or Azure Resource +Manager traffic by default. Because of that, Azure Arc runtime connectivity can remain +healthy even when the ARM path is degraded on a separate proxy or control-plane route. -| Parameter | Description | Default | -| --------- | ----------- | ------- | -| `-Region` | Azure region (e.g., `eastus2`, `brazilsouth`). Auto-detected from `azcmagent show` if omitted. | *(auto-detect or `eastus2`)* | -| `-Mode` | `Auto`, `Public`, `Private`, or `Gateway`. In `Auto`, the script checks `azcmagent show` for Private Link Scope or Gateway URL, then falls back to a DNS heuristic (`gbl.his.arc.azure.com` → private IP = Private). `Private` automatically adds `--enable-pls-check` to `azcmagent check`. | `Auto` | -| `-ProxyUrl` | Explicit HTTP/HTTPS proxy (e.g., `http://10.0.1.4:8443`). If omitted, auto-detects in order: `azcmagent proxy.url` → `HTTPS_PROXY` env var → WinHTTP (pre-onboarding only). | *(auto-detect)* | -| `-LogFilePath` | Path to the log file. Includes hostname automatically. | `C:\temp\ArcEndpointCheck_.txt` | -| `-SkipPKI` | Skips PKI/OCSP/CRL bypass validation and endpoint testing (not recommended). | *(off)* | -| `-SkipExtensions` | Skips all extension endpoint testing. Overrides `-CheckIncludeAll`. | *(off)* | -| `-CheckIncludeAll` | Tests ALL extension endpoints (even without agent detection). Also makes `azcmagent check` use `--extensions all --include-all`. Ideal for pre-onboarding validation. | *(off)* | +If Arc private-capable endpoints remain healthy and `azcmagent check` reports +`critical_failures=0`, the script treats ARM/proxy-path degradation as a +**non-blocking warning**, not a runtime outage. -## Using the Script +Typical example: -### Post-onboarding (agent installed) +- Arc private endpoints are reachable +- Guest Configuration endpoints are reachable +- AAD bypass works +- `management.azure.com` fails only through the configured proxy path +- `azcmagent check` reports `critical_failures=0` -```powershell -# Full auto — zero parameters needed -.\arcendpointcheck.ps1 +In that case, the script classifies the result as a warning such as: -# Force a specific region -.\arcendpointcheck.ps1 -Region brazilsouth +- `ControlPlane WARN` +- proxy-path non-blocking warning +- PKI/OCSP warning when relevant -# Force Private Link mode with verbose logging -.\arcendpointcheck.ps1 -Mode Private -Verbose +This avoids false outage conclusions in split-network environments. -# Test all extension endpoints (including not yet installed) -.\arcendpointcheck.ps1 -CheckIncludeAll -``` +### Important note about probe interpretation -### Pre-onboarding (agent not installed) +The script distinguishes between: -```powershell -# Minimum: region + test all extensions -.\arcendpointcheck.ps1 -Region eastus2 -CheckIncludeAll +- general endpoint reachability +- the effective path used by the Azure Arc agent or proxy configuration + +Because of that, an endpoint can appear reachable in a generic DNS/TCP probe while the +actual agent path still fails through the configured proxy. This distinction is +especially important for `management.azure.com` in `Private` mode. + +### When ARM failure is actionable -# Full pre-onboarding with proxy + Private Link -.\arcendpointcheck.ps1 -Region eastus2 -Mode Private -ProxyUrl http://10.0.1.4:8443 -CheckIncludeAll +A failed ARM path should be treated as operationally relevant when: -# Public mode, specific region, skip PKI (firewall team will handle) -.\arcendpointcheck.ps1 -Region brazilsouth -Mode Public -SkipPKI -CheckIncludeAll -``` +- onboarding is failing +- extension deployment or ARM-driven operations are failing +- the intended design requires ARM to traverse a private path +- the configured proxy should be carrying ARM traffic but is not reachable + +If ARM must stay private, configure **Resource Management Private Link** separately. +Do not assume Azure Arc Private Link Scope alone covers that path. + +## Prerequisites -### Azure Firewall explicit proxy scenarios +- **Windows PowerShell 5.1 or later** +- outbound connectivity to the required Azure Arc endpoints +- **optional:** Azure Connected Machine agent (`azcmagent.exe`) +- recommended: run from an **elevated PowerShell** session + +If `azcmagent` is not installed, the script runs in pre-onboarding mode and skips +agent-specific discovery such as `azcmagent check`, agent config, and extension +enumeration. + +## Parameters + +| Parameter | Description | Default | +| --------- | ----------- | ------- | +| `-Region` | Azure region. Auto-detected from `azcmagent show -j` when available. | auto-detect, otherwise `eastus2` fallback | +| `-Mode` | `Auto`, `Public`, `Private`, or `Gateway` | `Auto` | +| `-Platform` | `Auto`, `Arc`, `AzureLocal`, `AzureStackHub`, or `AzureVM` | `Auto` | +| `-ProxyUrl` | Explicit proxy override | auto-detect | +| `-LogFilePath` | Output log path | `C:\temp\ArcEndpointCheck_.txt` | +| `-SkipPKI` | Skip PKI / OCSP / CRL validation | off | +| `-SkipExtensions` | Skip extension endpoint checks | off | +| `-CheckIncludeAll` | Include all extension endpoint groups | off | +| `-SkipAgentHealth` | Skip local agent health inspection | off | + +## Getting Started + +Download the script and run it on the target server. + +### Post-onboarding ```powershell -# Validate connectivity through explicit proxy (auto-detected from azcmagent/WinHTTP) -.\arcendpointcheck.ps1 - -# Override proxy URL if not yet configured in azcmagent -.\arcendpointcheck.ps1 -ProxyUrl http://10.0.1.4:8443 -``` - -## Output Format - -The script produces pipe-delimited tables matching the `azcmagent check` style: - -### Header - -``` -========================================================================== - AZURE ARC ENDPOINT CHECK -========================================================================== - Host: SQLNODE1 - Time: 2026-07-03 17:38:51 - Agent: Installed | Connected | v1.65.03439.3010 - Region: eastus2 (auto-detected) - Mode: Private - Extensions: SQL, AMA, MDE, WAC, CT, UM, DSQL -``` - -### Proxy Configuration - -``` - Source | Proxy | Used By - -------------------+-------------------------------------+----------------------- - WinHTTP (OS) | http://10.0.1.4:8443 | SCHANNEL/OCSP/CRL - azcmagent | http://10.0.1.4:8443 | Arc Agent - HTTPS_PROXY | http://10.0.1.4:8443 | Extensions -``` - -### Results Table - -``` - Group | Endpoint | IP | Type | Result | Latency - ------+----------------------------------------------------+------------------+------+-----------+-------- - Core | login.windows.net | 20.190.173.132 | PUB | Reachable | 24ms - Core | gbl.his.arc.azure.com | 10.1.0.4 | PRIV | Reachable | 305ms - PKI | oneocsp.microsoft.com | 204.79.197.203 | PUB | Reachable | 33ms - SQL | dataprocessingservice.eastus2.arcdataservices.com | 72.153.30.41 | PUB | Reachable | 130ms -``` - -**Result values:** - -| Result | Meaning | -| ------ | ------- | -| `Reachable` | DNS + TCP + HTTP all passed | -| `Reachable*` | DNS + TCP passed, HTTP skipped (proxy.bypass active) | -| `FAIL(DNS)` | DNS resolution failed | -| `FAIL(TCP)` | TCP/443 connection timed out | -| `FAIL(DNS,TCP)` | Both DNS and TCP failed | -| `FAIL(HTTP)` | HTTP probe failed (proxy/firewall blocking) | -| `Warning` | Mode mismatch (e.g., Private mode but endpoint resolves to public IP) | - -### Issues Table - -``` - # | Severity | Category | Message - ----+----------+--------------+--------------------------------------------------- - 1 | CRITICAL | PKI Bypass | 2 PKI endpoint(s) not in proxy bypass - | | | Fix: Add to GPO NO_PROXY: crl4.digicert.com -``` - -### Summary Line - -``` - STATUS: PASS | OK:74 Fail:0 Warn:0 Issues:0 | Private eastus2 -``` - -Tags appended when applicable: `[GW]` for Gateway mode, `[PRE-ONBOARDING]` when agent -is not installed. - -## Exit Codes - -| Code | Status | Meaning | -| ---- | ------ | ------- | -| `0` | PASS | All checks passed | -| `0` | WARN | Only WARN/MEDIUM severity issues (e.g., Gateway bypass, Discovery) | -| `1` | FAIL | At least one CRITICAL or HIGH severity issue, or a DNS/TCP test failure | - -## Auto-Detection Logic - -The script automatically detects the following without any parameters: - -| What | Source | Fallback | -| ---- | ------ | -------- | -| **Region** | `azcmagent show -j` → `.location` | Default `eastus2` (with warning) | -| **Mode** | `azcmagent show -j` → `.privateLinkScope` / `.gatewayUrl` / `.connectionType` | DNS heuristic: `gbl.his.arc.azure.com` → private IP = Private | -| **Proxy** | `azcmagent config get proxy.url` → `HTTPS_PROXY` env → WinHTTP (pre-onboarding) | None (direct) | -| **Extensions** | `azcmagent extension list` (12 types: SQL, AMA, MDE, WAC, KV, HRW, CT, GA, UM, CS, DA, DSQL) | `-CheckIncludeAll` tests all | -| **Regional endpoints** | `azcmagent check` output parsing | DNS probe with abbreviation map (40+ regions) | -| **Gateway URL** | `azcmagent show -j` → `.gatewayUrl` | Manual `-Mode Gateway` | -| **Agent status** | `azcmagent show -j` → `.status` / `.agentVersion` | N/A | - -## PKI/OCSP/CRL Bypass Validation - -When a WinHTTP proxy is detected, the script validates that all PKI endpoints are in -the proxy bypass list (WinHTTP bypass or `NO_PROXY` environment variable). This is -critical for **Azure Firewall explicit proxy** deployments, where Windows SCHANNEL -sends OCSP/CRL requests through WinHTTP — if these endpoints are not bypassed, the -firewall rejects them with *"Received a non-proxy request on a proxy port"*. - -**PKI endpoints validated:** - -| Endpoint | Purpose | -| -------- | ------- | -| `oneocsp.microsoft.com` | OCSP primary | -| `crl.microsoft.com` | CRL Microsoft root | -| `crl2.microsoft.com` | CRL Microsoft intermediate | -| `crl3.digicert.com` | CRL DigiCert | -| `crl4.digicert.com` | CRL DigiCert alt | -| `ocsp.digicert.com` | OCSP DigiCert | -| `ctldl.windowsupdate.com` | Certificate Trust List | -| `www.microsoft.com` | PKI AIA chain | -| `caissuers.microsoft.com` | CA Issuers (AIA) | -| `login.live.com` | Live ID cert validation | - -The script normalizes both WinHTTP wildcard format (`*.domain.com`) and `NO_PROXY` -format (`.domain.com`) for accurate bypass matching. - -## TLS Validation - -Azure Arc requires **TLS 1.2 or 1.3**. The script performs a multi-layer check: - -1. **SCHANNEL registry** — verifies TLS 1.2 Client is not disabled via - `DisabledByDefault` or `Enabled=0`. -2. **Live handshake** — attempts a real TLS 1.2 connection to - `login.microsoftonline.com` (through proxy if configured). -3. **Cipher suites** — verifies required GCM ciphers are present: - - TLS 1.2: `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`, - `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` - - TLS 1.3: `TLS_AES_256_GCM_SHA384`, `TLS_AES_128_GCM_SHA256` -4. **.NET StrongCrypto** — checks `SchUseStrongCrypto` registry (recommended for - PS 5.1). -5. **Server 2012 (non-R2)** — warns that SQL Arc (`*.arcdataservices.com`) is not - supported. - -## References - -- [Azure Arc network requirements (consolidated)](https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated) -- [Azure Arc Gateway](https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway) -- [Azure Firewall explicit proxy with Arc](https://learn.microsoft.com/azure/azure-arc/azure-firewall-explicit-proxy) -- [Troubleshoot Windows TLS configuration](https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues) -- [Private Link for Azure Arc](https://learn.microsoft.com/azure/azure-arc/servers/private-link-security) +# Full auto-detection +.\arc-endpoint-check-revised.ps1 + +# Force Private mode +.\arc-endpoint-check-revised.ps1 -Mode Private + +# Force a specific region +.\arc-endpoint-check-revised.ps1 -Region eastus2 + +# Test all extension groups +.\arc-endpoint-check-revised.ps1 -CheckIncludeAll From 4d1fcadbe5a730237e9c831f9032d609ab003df9 Mon Sep 17 00:00:00 2001 From: Fabio Rodrigues Vieira Costa <38567767+fabiotreze@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:30:21 +0000 Subject: [PATCH 10/10] Refine ArcEndpointCheck.ps1 and _index.md (post-#1908) --- .../arc_endpoint_check/ArcEndpointCheck.ps1 | 3065 +++++++++-------- 1 file changed, 1638 insertions(+), 1427 deletions(-) diff --git a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 index 52e26800..88f3ec2f 100644 --- a/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 +++ b/script_automation/arc_endpoint_check/ArcEndpointCheck.ps1 @@ -1,111 +1,61 @@ -#Requires -Version 5.1 - <# .SYNOPSIS - Validates Azure Arc connectivity, DNS, TCP, HTTP, TLS, proxy, PKI bypass, - and extension endpoints for Arc-enabled servers. + Validates Azure Arc connectivity, proxy behavior, agent local health, DNS, TCP, HTTP, + TLS, PKI bypass, and optional platform-specific metadata endpoints. .DESCRIPTION - Auto-detects EVERYTHING: region, connectivity mode (Public/Private/Gateway), - proxy configuration, installed extensions, and regional endpoints. - - Connectivity modes supported (per Microsoft docs): - - Public: Direct internet or via forward proxy - - Private: Azure Private Link Scope (PLS) - - Gateway: Azure Arc Gateway (reduces endpoints to ~7 FQDNs) - - Diagnostic sequence (execution order): - 1. System & Agent Context - OS, agent version/status, mode, extensions - 2. Proxy Chain - Detects and displays ALL proxy sources: - - WinHTTP (OS/GPO) → used by SCHANNEL (PKI/CRL/OCSP) - - azcmagent proxy.url → used by Arc Agent core - - HTTPS_PROXY env → used by Extensions (.NET/Python) - - Upstream proxy → used in Gateway chain - Precedence for tests: -ProxyUrl > azcmagent > HTTPS_PROXY > WinHTTP - 3. TLS & Crypto - SCHANNEL registry, .NET Framework, cipher suites, - TLS 1.2 handshake test, registry dump (actual vs recommended) - 4. PKI/OCSP/CRL Bypass - Validates which PKI endpoints are in proxy bypass: - - Bypassed → SCHANNEL connects DIRECT (test without proxy) - - Not bypassed → SCHANNEL uses WinHTTP proxy (test via proxy) - Detects "non-proxy request on proxy port" failures - 5. Endpoint Discovery - azcmagent check + DNS-based regional discovery - 6. Connectivity Tests - Test strategy per proxy scenario: - - TCP/443: Direct L3/L4 (never uses proxy — validates firewall) - - HTTP probe: Via effective proxy (validates app-layer path) - - Gateway tunneled: Skips TCP (traffic goes localhost:40343) - - PKI probe: Direct or via WinHTTP (per bypass list) - - SQL TLS probe: TLS 1.2 handshake to arcdataservices.com - 7. Results & Issues - Summary table, issues with fix recommendations - - Validates: - - Core Arc agent endpoints (HIMDS, GuestConfig, GNS, AAD, ARM, MCR) - - Regional endpoints discovered from 'azcmagent check' - - Extension endpoints: SQL, AMA, MDE, WAC, KV, HRW, UM, GA, Defender for SQL - - PKI/OCSP/CRL proxy bypass (detects "non-proxy request on proxy port") - - TLS 1.2+ with required GCM cipher suites (agent 1.56+) - - Arc Gateway tunneled vs direct endpoint classification - - SQL Arc specific TLS 1.2 handshake to arcdataservices.com - - Run with ZERO parameters for full auto-detection: - PS> .\arcendpointcheck.ps1 + Refactored version of Arc endpoint validation with safer semantics for explicit proxy + environments and clearer platform separation. + + In Private mode, this script distinguishes the Azure Arc private connectivity path from + Microsoft Entra ID and Azure Resource Manager control-plane traffic. Azure Arc Private + Link Scope does not carry ARM traffic by default, so a failed path to + management.azure.com can be reported as a non-blocking WARN when azcmagent shows no + critical Arc connectivity failures and Arc private-capable endpoints remain healthy. + + Key improvements: + - Proxy-aware TCP result classification (avoids false FAIL for direct TCP in proxy mode) + - Agent local health section (services, version, config, last himds log lines) + - Optional platform checks for AzureLocal / AzureStackHub / AzureVM + - Reduced reliance on static endpoint assumptions when azcmagent is available + - More conservative TLS/cipher reporting + - Private-mode handling that separates Arc private-link health from ARM control-plane health .PARAMETER Region - Azure region. Auto-detected from azcmagent if omitted. + Azure region. Auto-detected from azcmagent when possible. .PARAMETER Mode Auto | Public | Private | Gateway. Default: Auto. +.PARAMETER Platform + Auto | Arc | AzureLocal | AzureStackHub | AzureVM. Default: Auto. + .PARAMETER ProxyUrl Override proxy URL. Auto-detected if omitted. .PARAMETER LogFilePath - Log file path. Default: C:\temp\Arclogfile.txt. + Log file path. Default: C:\temp\ArcEndpointCheck_.txt. .PARAMETER SkipPKI - Skips PKI/OCSP/CRL testing (not recommended). + Skips PKI/OCSP/CRL testing. .PARAMETER SkipExtensions Skips extension endpoint testing. -.PARAMETER GatewayUrl - Arc Gateway URL for pre-onboarding (when agent is not installed). - Auto-detected from azcmagent if omitted. - .PARAMETER CheckIncludeAll - Makes 'azcmagent check' use '--extensions all --include-all'. + Makes azcmagent check use '--extensions all --include-all'. -.EXAMPLE - PS> .\arcendpointcheck.ps1 - Full auto: detects region, mode, proxy, extensions. Zero parameters needed. +.PARAMETER SkipAgentHealth + Skips local agent health inspection. .EXAMPLE - PS> .\arcendpointcheck.ps1 -Region brazilsouth -Mode Private - Forces region and mode override. + .\arc-endpoint-check-revised.ps1 .EXAMPLE - PS> .\arcendpointcheck.ps1 -Region eastus2 -Mode Gateway -GatewayUrl https://mygateway.gw.arc.azure.com -ProxyUrl http://10.0.1.4:8443 - Pre-onboarding Gateway: agent not installed but gateway URL is known. + .\arc-endpoint-check-revised.ps1 -Region brazilsouth -Mode Private .EXAMPLE - PS> .\arcendpointcheck.ps1 -Region eastus2 -Mode Private -ProxyUrl http://10.0.1.4:8443 -CheckIncludeAll - Pre-onboarding: agent not installed. Specify region, mode, proxy, and test all extensions. - -.NOTES - Requires PowerShell 5.1+ on Windows (Server 2012 R2+ with WMF 5.1, or Server 2016+ native). - Minimum OS: Windows Server 2012 R2 (with WMF 5.1 installed). - Does NOT require Administrator (recommended but not mandatory). - - References: - - https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated - - https://learn.microsoft.com/azure/azure-arc/servers/arc-gateway - - https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues - - https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint - - https://learn.microsoft.com/azure/azure-monitor/agents/azure-monitor-agent-network-configuration - - https://learn.microsoft.com/defender-endpoint/configure-proxy-internet - - https://learn.microsoft.com/entra/identity/hybrid/connect/reference-connect-tls-enforcement - -.LINK - https://azurearcjumpstart.com + .\arc-endpoint-check-revised.ps1 -Platform AzureLocal -Mode Gateway -ProxyUrl http://10.0.1.4:8443 #> [CmdletBinding()] @@ -115,43 +65,21 @@ param( [ValidateSet('Auto', 'Public', 'Private', 'Gateway')] [string]$Mode = 'Auto', - [string]$ProxyUrl, + [ValidateSet('Auto', 'Arc', 'AzureLocal', 'AzureStackHub', 'AzureVM')] + [string]$Platform = 'Auto', - [string]$GatewayUrl, + [string]$ProxyUrl, [string]$LogFilePath = "C:\temp\ArcEndpointCheck_$($env:COMPUTERNAME).txt", [switch]$SkipPKI, [switch]$SkipExtensions, - [switch]$CheckIncludeAll + [switch]$CheckIncludeAll, + [switch]$SkipAgentHealth ) -# ========================================================================= -# SETUP -# ========================================================================= $ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' -$script:Version = '2.0.0' -$script:Updated = '2026-07-08' - -# Enable TLS 1.2 for this session (required on systems without SchUseStrongCrypto) -# 3072 = [Net.SecurityProtocolType]::Tls12 -[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 - -# --- Prerequisites Check --- -$script:IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $script:IsAdmin) { - Write-Host ' [WARN] Not running as Administrator. Some checks may be limited.' -ForegroundColor Yellow - Write-Host ' Recommend: Right-click PowerShell > Run as Administrator' -ForegroundColor DarkYellow - Write-Host '' -} - -# Verify Resolve-DnsName is available (DnsClient module, Server 2012+ / Win8+) -if (-not (Get-Command 'Resolve-DnsName' -ErrorAction SilentlyContinue)) { - Write-Host ' [ERROR] Resolve-DnsName not available. Requires Windows Server 2012+ / Win8+.' -ForegroundColor Red - Write-Host ' This script cannot run on this OS.' -ForegroundColor Red - exit 1 -} +$ProgressPreference = 'SilentlyContinue' $logDir = Split-Path -Path $LogFilePath -Parent if ($logDir -and -not (Test-Path $logDir)) { @@ -159,236 +87,802 @@ if ($logDir -and -not (Test-Path $logDir)) { } Set-Content -Path $LogFilePath -Value "ArcEndpointCheck started at $(Get-Date -Format o)" -Force -$script:Stats = [ordered]@{ OK = 0; Fail = 0; Warn = 0 } -$script:Log = [System.Collections.ArrayList]::new() -$script:Results = [System.Collections.ArrayList]::new() -$script:Issues = [System.Collections.ArrayList]::new() - -# ========================================================================= -# HELPERS -# ========================================================================= +$script:Stats = [ordered]@{ + DNSOK = 0; DNSWarn = 0; DNSFail = 0 + TCPOK = 0; TCPWarn = 0; TCPFail = 0 + HTTPOK = 0; HTTPWarn = 0; HTTPFail = 0 +} +$script:Log = New-Object System.Collections.ArrayList +$script:Results = New-Object System.Collections.ArrayList +$script:Issues = New-Object System.Collections.ArrayList +$script:InstalledExts = @() +$script:AgentJson = $null +$script:EffectiveProxy = $null +$script:WinHttpProxy = $null +$script:WinHttpBypass = $null +$script:GatewayUrl = $null +$script:AgentConfigDump = $null +$script:ProxyMode = 'Direct' +$script:EffectiveProxyReachable = $null +$script:EffectiveProxyParseError = $null +$script:AzcmagentCheckExit = $null +$script:AzcmagentEndpointMeta = @{} +$script:AzcmagentFailedEndpoints = New-Object System.Collections.ArrayList +$script:AzcmagentChecksFailed = $null +$script:AzcmagentCriticalFailures = $null +$script:AzcmagentCoreHealthy = $null +$script:AzcmagentPrivatePathHealthy = $false +$script:PreOnboarding = $false +$script:DeferredTlsIssue = $null +$script:ScenarioSummary = @() +$script:DisclaimerLines = @( + 'Disclaimer: This script was updated with support from Azure SRE Agent.', + 'Responsible AI documentation: Microsoft Responsible AI principles and approach: https://www.microsoft.com/en-us/ai/principles-and-approach', + 'Responsible AI governance overview: Microsoft Artificial Intelligence overview / Responsible AI Standard: https://learn.microsoft.com/en-us/compliance/assurance/assurance-artificial-intelligence', + 'Validation note: Results were tested and validated in 3 scenarios with direct user oversight, follow-up, and user-made changes during execution review.' +) +Add-Content -Path $LogFilePath -Value '' +Add-Content -Path $LogFilePath -Value '=================== DISCLAIMER ===================' +Add-Content -Path $LogFilePath -Value $script:DisclaimerLines function Write-Banner { - param([string]$T) - $w = 74 + param([string]$Text) + $w = 82 Write-Host '' Write-Host ('=' * $w) -ForegroundColor DarkCyan - Write-Host " $T" -ForegroundColor Cyan + Write-Host " $Text" -ForegroundColor Cyan Write-Host ('=' * $w) -ForegroundColor DarkCyan } function Write-Section { - param([string]$T) + param([string]$Text) Write-Host '' - Write-Host " --- $T ---" -ForegroundColor DarkGray + Write-Host " --- $Text ---" -ForegroundColor DarkGray } function Write-Status { param([string]$Label, [string]$Value, [string]$Color = 'White') - Write-Host (" {0,-22} {1}" -f "${Label}:", $Value) -ForegroundColor $Color -} - -function Test-IsValidProxyUri { - param([string]$C) - if (-not $C) { return $false } - $p = $null - return ( - [System.Uri]::TryCreate($C, [System.UriKind]::Absolute, [ref]$p) -and - $p.Scheme -in @('http', 'https') - ) + Write-Host (" {0,-24} {1}" -f ("${Label}:"), $Value) -ForegroundColor $Color } function Log { param( [string]$Msg, - [ValidateSet('Info', 'OK', 'Fail', 'Warn')][string]$Lv = 'Info', - [switch]$NoCount + [ValidateSet('INFO', 'OK', 'WARN', 'FAIL')][string]$Level = 'INFO' ) - $line = "[$(Get-Date -Format HH:mm:ss)] [$($Lv.ToUpper().PadRight(4))] $Msg" + $line = "[$(Get-Date -Format HH:mm:ss)] [$Level] $Msg" [void]$script:Log.Add($line) - Write-Verbose $line - if (-not $NoCount) { - if ($Lv -eq 'OK') { $script:Stats.OK++ } - if ($Lv -eq 'Fail') { $script:Stats.Fail++ } - if ($Lv -eq 'Warn') { $script:Stats.Warn++ } +} + +function Save-Log { + if ($script:Log.Count -gt 0) { + Add-Content -Path $LogFilePath -Value $script:Log + $script:Log.Clear() + } +} + +function Add-Issue { + param( + [ValidateSet('CRITICAL', 'HIGH', 'MEDIUM', 'WARN', 'INFO')][string]$Severity, + [string]$Category, + [string]$Message, + [string]$Fix = '' + ) + [void]$script:Issues.Add([pscustomobject]@{ + Severity = $Severity + Category = $Category + Message = $Message + Fix = $Fix + }) +} + +function Convert-IssuesToNonBlockingWarnings { + foreach ($issue in $script:Issues) { + switch ($issue.Category) { + 'Proxy' { + $issue.Severity = 'WARN' + if ($issue.Message -notmatch 'non-blocking' -and $issue.Message -notmatch 'Private/split-network mode') { + $issue.Message = ($issue.Message.TrimEnd('.') + '. Treat this as a non-blocking proxy-path warning because azcmagent reported no critical Arc connectivity failures.') + } + if ($issue.Fix -and $issue.Fix -notmatch 'critical_failures=0' -and $issue.Fix -notmatch 'no critical Arc connectivity failures') { + $issue.Fix = ($issue.Fix.TrimEnd('.') + '. Do not treat this alone as Arc core outage when azcmagent reports critical_failures=0.') + } + } + } } } function Add-Result { param( - [string]$Endpoint, [string]$Group = 'Core', [string]$IP = '-', - [string]$Type = '-', [string]$DNS = '-', [string]$TCP = '-', - [string]$HTTP = '-', [string]$Latency = '-', [string]$Path = '-' + [string]$Endpoint, + [string]$Group = '', + [string]$IP = '-', + [string]$Type = '-', + [string]$DNS = '-', + [string]$TCP = '-', + [string]$HTTP = '-', + [string]$Latency = '-', + [string]$Notes = '' ) - $ex = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } - if ($ex) { - if ($IP -ne '-') { $ex.IP = $IP } - if ($Type -ne '-') { $ex.Type = $Type } - if ($DNS -ne '-') { $ex.DNS = $DNS } - if ($TCP -ne '-') { $ex.TCP = $TCP } - if ($HTTP -ne '-') { $ex.HTTP = $HTTP } - if ($Latency -ne '-') { $ex.Latency = $Latency } - if ($Path -ne '-') { $ex.Path = $Path } + $existing = $script:Results | Where-Object { $_.Endpoint -eq $Endpoint } | Select-Object -First 1 + if ($existing) { + foreach ($k in 'Group','IP','Type','DNS','TCP','HTTP','Latency','Notes') { + $v = Get-Variable -Name $k -ValueOnly + if ($k -eq 'Group' -and [string]::IsNullOrWhiteSpace($v)) { + continue + } + if ($null -ne $v -and $v -ne '-' -and $v -ne '') { + $existing.$k = $v + } + } } else { - [void]$script:Results.Add([ordered]@{ - Endpoint = $Endpoint; Group = $Group; IP = $IP; Type = $Type - DNS = $DNS; TCP = $TCP; HTTP = $HTTP; Latency = $Latency; Path = $Path + [void]$script:Results.Add([pscustomobject]@{ + Endpoint = $Endpoint + Group = $(if ([string]::IsNullOrWhiteSpace($Group)) { 'Core' } else { $Group }) + IP = $IP + Type = $Type + DNS = $DNS + TCP = $TCP + HTTP = $HTTP + Latency = $Latency + Notes = $Notes }) } } -function Add-Issue { - param([string]$Sev, [string]$Cat, [string]$Msg, [string]$Fix = '') - [void]$script:Issues.Add([ordered]@{ - Severity = $Sev; Category = $Cat; Message = $Msg; Fix = $Fix - }) +function Test-IsValidProxyUri { + param([string]$Candidate) + if (-not $Candidate) { return $false } + $uri = $null + return [System.Uri]::TryCreate($Candidate, [System.UriKind]::Absolute, [ref]$uri) -and $uri.Scheme -in @('http', 'https') } -function Save-Log { - if ($script:Log.Count -gt 0) { - Add-Content -Path $LogFilePath -Value $script:Log - $script:Log.Clear() +function Get-AzcmagentPath { + $candidate = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' + if (Test-Path $candidate) { return $candidate } + return $null +} + +function Test-IsPrivateIp { + param([string]$Ip) + if (-not $Ip) { return $false } + try { + $b = ([System.Net.IPAddress]::Parse($Ip)).GetAddressBytes() + } + catch { + return $false } + return ($b[0] -eq 10) -or + ($b[0] -eq 192 -and $b[1] -eq 168) -or + ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) -or + ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) +} + +function Get-OperatingSystemInfo { + try { + if (Get-Command -Name Get-CimInstance -ErrorAction SilentlyContinue) { + return Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + } + } + catch { } + + try { + return Get-WmiObject -Class Win32_OperatingSystem -ErrorAction Stop + } + catch { + return $null + } +} + +function Invoke-HttpSafe { + param( + [string]$Uri, + [int]$TimeoutSec = 10, + [string]$UseProxy = '', + [switch]$NoProxy + ) + + $params = @{ + Uri = $Uri + Method = 'Get' + UseBasicParsing = $true + TimeoutSec = $TimeoutSec + ErrorAction = 'Stop' + } + + if ($NoProxy) { + if ($PSVersionTable.PSVersion.Major -ge 6) { + $params['NoProxy'] = $true + } + else { + $saved = [System.Net.WebRequest]::DefaultWebProxy + try { + [System.Net.WebRequest]::DefaultWebProxy = $null + return Invoke-WebRequest @params + } + finally { + [System.Net.WebRequest]::DefaultWebProxy = $saved + } + } + } + else { + $proxyToUse = if ($UseProxy) { $UseProxy } else { $script:EffectiveProxy } + if ($proxyToUse) { + $params['Proxy'] = $proxyToUse + $params['ProxyUseDefaultCredentials'] = $true + } + elseif ($PSVersionTable.PSVersion.Major -ge 6) { + $params['NoProxy'] = $true + } + } + + return Invoke-WebRequest @params } function Test-TcpPort { - param([string]$H, [int]$P = 443, [int]$T = 5000) - $c = [System.Net.Sockets.TcpClient]::new() + param( + [string]$HostName, + [int]$Port = 443, + [int]$TimeoutMs = 5000 + ) + + $client = New-Object System.Net.Sockets.TcpClient try { - $r = $c.BeginConnect($H, $P, $null, $null) - if ($r.AsyncWaitHandle.WaitOne($T, $false) -and $c.Connected) { - $c.EndConnect($r) | Out-Null + $ar = $client.BeginConnect($HostName, $Port, $null, $null) + if ($ar.AsyncWaitHandle.WaitOne($TimeoutMs, $false) -and $client.Connected) { + $client.EndConnect($ar) | Out-Null return $true } return $false } - catch { return $false } - finally { $c.Close() } + catch { + return $false + } + finally { + $client.Close() + } } -function Invoke-HttpSafe { - param([string]$Uri, [int]$Timeout = 10, [string]$UseProxy = '') - $p = @{ - Uri = $Uri; Method = 'Get'; UseBasicParsing = $true - TimeoutSec = $Timeout; ErrorAction = 'Stop' +function Resolve-Endpoint { + param([string]$Endpoint) + $resolved = $null + $err = $null + foreach ($attempt in 1..2) { + try { + if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) { + $resolved = Resolve-DnsName -Name $Endpoint -ErrorAction Stop + } + else { + $addresses = [System.Net.Dns]::GetHostAddresses($Endpoint) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } + $resolved = @($addresses | ForEach-Object { + [pscustomobject]@{ + Name = $Endpoint + Type = 'A' + IPAddress = $_.IPAddressToString + } + }) + } + $err = $null + break + } + catch { + $err = $_ + Start-Sleep -Milliseconds 250 + } } - $px = if ($UseProxy) { $UseProxy } else { $script:EffectiveProxy } - if ($px) { - $p['Proxy'] = $px - $p['ProxyUseDefaultCredentials'] = $true + return [pscustomobject]@{ + Result = $resolved + Error = $err } - elseif ($PSVersionTable.PSVersion.Major -ge 6) { - $p['NoProxy'] = $true +} + +function Get-TlsCipherInventory { + $result = [pscustomobject]@{ + Supported = $false + Names = @() + Error = $null + } + + try { + if (-not (Get-Command -Name Get-TlsCipherSuite -ErrorAction SilentlyContinue)) { + $result.Error = 'Get-TlsCipherSuite is not available on this PowerShell/OS version.' + return $result + } + + $cipherNames = @(Get-TlsCipherSuite -ErrorAction Stop | ForEach-Object { $_.Name } | Where-Object { $_ }) + $result.Supported = $true + $result.Names = $cipherNames + return $result + } + catch { + $result.Error = $_.Exception.Message + return $result } - Invoke-WebRequest @p } -function Get-AzcmagentPath { - $c = Join-Path $env:ProgramFiles 'AzureConnectedMachineAgent\azcmagent.exe' - if (Test-Path $c) { return $c } - return $null +function Test-AcceptableHttpCode { + param( + [string]$Endpoint, + [int]$Code + ) + + $patterns = @( + @{ Match = '^management\.azure\.com$'; Codes = @(200, 301, 302, 400, 401, 403) }, + @{ Match = '^login\.windows\.net$'; Codes = @(200, 301, 302, 400, 401, 403, 404) }, + @{ Match = '^login\.microsoftonline\.com$'; Codes = @(200, 301, 302, 400, 401, 403, 404) }, + @{ Match = '^.+\.login\.microsoft\.com$'; Codes = @(200, 301, 302, 400, 401, 403, 404) }, + @{ Match = '^dataprocessingservice\..+\.arcdataservices\.com$'; Codes = @(200, 301, 302, 400, 401, 403, 404) }, + @{ Match = '^telemetry\..+\.arcdataservices\.com$'; Codes = @(200, 301, 302, 400, 401, 403, 404) }, + @{ Match = '^oneocsp\.microsoft\.com$'; Codes = @(200, 301, 302, 400, 401, 403, 404, 405) } + ) + + foreach ($p in $patterns) { + if ($Endpoint -match $p.Match) { + return ($p.Codes -contains $Code) + } + } + + return ($Code -ge 200 -and $Code -lt 500) } -function Test-IsPrivateIp { - param([string]$Ip) - if (-not $Ip) { return $false } - try { $b = ([System.Net.IPAddress]::Parse($Ip)).GetAddressBytes() } - catch { return $false } - return ($b[0] -eq 10) -or - ($b[0] -eq 192 -and $b[1] -eq 168) -or - ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) -or - ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) +function Convert-AzcmagentUseCaseToGroup { + param([string]$UseCase) + + $normalizedUseCase = if ([string]::IsNullOrEmpty($UseCase)) { '' } else { $UseCase.ToLowerInvariant() } + switch ($normalizedUseCase) { + 'core' { return 'Core' } + 'sql' { return 'SQL' } + 'paygo' { return 'PAYGO' } + default { + if ($UseCase) { return $UseCase.ToUpperInvariant() } + return 'Core' + } + } } -function Test-IsGatewayTunneled { +function Test-IsArcDataEndpoint { param([string]$Endpoint) - if ($Mode -ne 'Gateway') { return $false } - foreach ($pattern in $script:GatewayTunneledPatterns) { - if ($pattern.StartsWith('*.')) { - $suffix = $pattern.Substring(1) # e.g. '.his.arc.azure.com' - if ($Endpoint.EndsWith($suffix) -or $Endpoint -eq $pattern.Substring(2)) { return $true } - } - elseif ($Endpoint -eq $pattern) { return $true } + + if (-not $Endpoint) { return $false } + return $Endpoint -match '^(dataprocessingservice|telemetry|defender-for-databases)\..+\.arcdataservices\.com$' +} + +function Test-IsOptionalDynamicEndpoint { + param( + [string]$Endpoint, + [string]$Group + ) + + if ($Group -eq 'GNS') { + return $true } + return $false } -# ========================================================================= -# PHASE 1: SYSTEM & AGENT CONTEXT -# ========================================================================= +function Test-IsOptionalEndpoint { + param( + [string]$Endpoint, + [string]$Group + ) -Write-Banner 'AZURE ARC ENDPOINT CHECK' -Write-Host " Version $($script:Version) ($($script:Updated))" -ForegroundColor DarkGray -Write-Host '' -Write-Host ' Diagnostic sequence:' -ForegroundColor DarkGray -Write-Host ' 1. System & Agent Context (OS, agent version, mode, extensions)' -ForegroundColor DarkGray -Write-Host ' 2. Proxy Chain (WinHTTP -> Agent -> Env -> Gateway)' -ForegroundColor DarkGray -Write-Host ' 3. TLS & Crypto (SCHANNEL + .NET + ciphers + registry dump)' -ForegroundColor DarkGray -Write-Host ' 4. PKI/OCSP/CRL Bypass (certificate validation path)' -ForegroundColor DarkGray -Write-Host ' 5. Endpoint Discovery (azcmagent check + regional)' -ForegroundColor DarkGray -Write-Host ' 6. Connectivity Tests (DNS + TCP + HTTP probes)' -ForegroundColor DarkGray -Write-Host ' 7. Results & Issues (summary table)' -ForegroundColor DarkGray -Write-Host '' + if ($Endpoint -eq 'dc.services.visualstudio.com') { + return $true + } + + if (Test-IsArcDataEndpoint -Endpoint $Endpoint) { + return $true + } -Write-Section 'System & Agent' + if (Test-IsOptionalDynamicEndpoint -Endpoint $Endpoint -Group $Group) { + return $true + } + + if ($Group -eq 'Lifecycle') { + return (-not $script:PreOnboarding) + } + + if ($Group -eq 'ControlPlane') { + if ($Mode -eq 'Private') { + return $false + } + return (-not $script:PreOnboarding) + } + + return $Group -in @('SQL', 'AMA', 'MDE', 'WAC', 'KV', 'HRW', 'UM', 'GA', 'PAYGO', 'GNS') +} + +function Test-IsLegacyTls12OnlyOs { + param($OsInfo) + + if ($null -eq $OsInfo) { return $false } + + $versionText = '' + if ($OsInfo.PSObject.Properties['Version']) { + $versionText = [string]$OsInfo.Version + } + + if ($versionText -match '^6\.(2|3)') { + return $true + } + + $caption = '' + if ($OsInfo.PSObject.Properties['Caption']) { + $caption = [string]$OsInfo.Caption + } + + return ($caption -match 'Windows Server 2012') +} + +function Test-IsArcDataTlsPathError { + param( + [string]$Endpoint, + [string]$ErrorText + ) + + if (-not (Test-IsArcDataEndpoint -Endpoint $Endpoint)) { return $false } + if (-not $ErrorText) { return $false } + + return ($ErrorText -match 'SEC_E_ILLEGAL_MESSAGE|unexpected or badly formatted|0x80090326') +} + +function Get-AgentBypassedEndpoints { + param( + [string]$AgentBypass, + [string]$Region + ) + + $bypassedEndpoints = New-Object System.Collections.ArrayList + if (-not $AgentBypass) { return $bypassedEndpoints } + + $normalizedBypass = $AgentBypass.Trim() + $bypassTokens = @() + if ($normalizedBypass -match '^\s*\[') { + try { + $parsedBypass = $normalizedBypass | ConvertFrom-Json -ErrorAction Stop + $bypassTokens = @($parsedBypass | ForEach-Object { [string]$_ }) + } + catch { + $bypassTokens = @($normalizedBypass -split ',') + } + } + else { + $bypassTokens = @($normalizedBypass -split ',') + } + + $bypassTokens = @( + $bypassTokens | + ForEach-Object { [string]$_ } | + ForEach-Object { $_.Trim().Trim('"').Trim("'").Trim('[', ']') } | + Where-Object { $_ } + ) + + $bypassMap = @{ + 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') + 'ARM' = @('management.azure.com') + 'Arc' = @('*.his.arc.azure.com', '*.guestconfiguration.azure.com') + 'ArcData' = @("dataprocessingservice.$Region.arcdataservices.com", "telemetry.$Region.arcdataservices.com") + 'AMA' = @('global.handler.control.monitor.azure.com', "$Region.handler.control.monitor.azure.com") + } + + foreach ($token in $bypassTokens) { + if ($bypassMap.ContainsKey($token)) { + foreach ($ep in $bypassMap[$token]) { + if ($bypassedEndpoints -notcontains $ep) { [void]$bypassedEndpoints.Add($ep) } + } + } + } + + return $bypassedEndpoints +} + +function Get-HttpStatus { + param( + [string]$Endpoint, + [switch]$ForceDirect, + [string]$ProxyOverride = '' + ) + + try { + $response = Invoke-HttpSafe -Uri "https://$Endpoint" -TimeoutSec 10 -UseProxy $ProxyOverride -NoProxy:$ForceDirect + return [pscustomobject]@{ Success = $true; StatusCode = [int]$response.StatusCode; Error = $null } + } + catch { + $statusCode = $null + if ($_.Exception.Response) { + try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { } + } + if ($statusCode -and (Test-AcceptableHttpCode -Endpoint $Endpoint -Code $statusCode)) { + return [pscustomobject]@{ Success = $true; StatusCode = $statusCode; Error = $_.Exception.Message } + } + return [pscustomobject]@{ Success = $false; StatusCode = $statusCode; Error = $_.Exception.Message } + } +} + +function Get-HttpFailureDiagnosis { + param( + [string]$Endpoint, + [string]$Group, + [string]$ErrorText, + [Nullable[int]]$StatusCode, + [bool]$UsingExplicitProxy, + [bool]$ForceDirect, + [bool]$CoreHealthyNoCritical + ) + + $messageText = if ($ErrorText) { $ErrorText } else { '' } + $proxyConfiguredButDown = $false + if ($UsingExplicitProxy -and $script:EffectiveProxy) { + try { + $proxyUri = [System.Uri]$script:EffectiveProxy + $proxyConfiguredButDown = -not (Test-TcpPort -HostName $proxyUri.Host -Port $proxyUri.Port -TimeoutMs 2500) + } + catch { } + } + + $cause = 'insufficient evidence' + $notes = 'HTTP probe failed; evidence is insufficient to attribute the failure to a specific network control.' + $fix = 'Capture proxy/firewall logs for the failing timestamp and compare with a direct test from the same host if allowed.' + $category = if ($UsingExplicitProxy -and -not $ForceDirect) { 'ProxyPath' } else { 'HTTP' } + + if ($proxyConfiguredButDown) { + $cause = 'proxy endpoint unreachable' + $notes = 'Configured proxy endpoint itself is not reachable from this host; do not attribute this probe failure to the destination endpoint yet.' + $fix = 'Validate routing, firewall, listener state, and service health for the configured proxy endpoint before reviewing destination-specific rules.' + $category = 'Proxy' + } + elseif ($StatusCode -eq 407 -or $messageText -match '407') { + $cause = 'proxy authentication required' + $notes = 'Proxy requested authentication for the HTTP probe.' + $fix = 'Validate proxy authentication policy and whether machine/default credentials are accepted for this traffic.' + } + elseif ($StatusCode -in @(502, 503, 504)) { + $cause = 'proxy upstream or app rule issue' + $notes = "Proxy returned HTTP $StatusCode while attempting to reach the destination; this usually points to upstream denial, app rule mismatch, or upstream unavailability." + $fix = 'Review proxy app rules, upstream connectivity, and destination allow policy for this endpoint.' + } + elseif ($messageText -match 'actively refused|refused it|connection refused|No connection could be made because the target machine actively refused it') { + if ($UsingExplicitProxy -and -not $ForceDirect) { + $cause = 'proxy refused connection' + $notes = 'The proxy path refused the TCP/CONNECT attempt before the destination flow completed.' + $fix = 'Validate the proxy listener, proxy service health, and any local firewall or route to the configured proxy.' + $category = 'Proxy' + } + else { + $cause = 'destination refused connection' + $notes = 'The remote side actively refused the connection.' + $fix = 'Validate destination listener expectations, intermediate filtering, and whether direct access is actually supported.' + } + } + elseif ($messageText -match 'timed out|operation has timed out|The operation timed out|A task was canceled|The request was aborted') { + $cause = 'timeout or routing issue' + $notes = 'The probe timed out before a valid HTTP response was returned; this commonly indicates routing blackhole, silent filtering, or overloaded middlebox behavior.' + $fix = 'Validate routing, NSG/firewall state, and whether a proxy or middlebox is silently dropping the flow.' + } + elseif ($messageText -match 'name could not be resolved|remote name could not be resolved|No such host is known') { + $cause = 'dns resolution issue' + $notes = 'The HTTP client could not resolve the target host name for this probe.' + $fix = 'Review local DNS resolution and any proxy DNS dependency for this endpoint.' + } + elseif ((Test-IsArcDataTlsPathError -Endpoint $Endpoint -ErrorText $messageText) -or $messageText -match 'SEC_E_ILLEGAL_MESSAGE|0x80090326|unexpected or badly formatted|The SSL connection could not be established|authentication or decryption has failed|Could not create SSL/TLS secure channel') { + $cause = 'tls inspection or handshake interference' + $notes = 'The probe failed during TLS negotiation; this often points to TLS inspection, protocol handling mismatch, or middlebox interference on the path.' + $fix = 'Review TLS inspection, certificate substitution, outbound SSL policy, and any middlebox handling on this path.' + } + elseif ($StatusCode -eq 403) { + $cause = 'application layer deny' + $notes = 'The path returned HTTP 403, which suggests the request reached an enforcing layer but was denied by policy.' + $fix = 'Review proxy app rules, destination ACLs, and any path-based or host-based access policy for this endpoint.' + } + + return [pscustomobject]@{ + Cause = $cause + Notes = $notes + Fix = $fix + Category = $category + ProxyConfiguredButDown = $proxyConfiguredButDown + } +} + +function Get-AgentHealth { + param([string]$AzcmPath) + + Write-Section 'Agent Local Health' + + if (-not $AzcmPath) { + Write-Status 'Agent health' 'Skipped - azcmagent not installed' Yellow + return + } + + try { + $version = (& $AzcmPath version 2>$null | Out-String).Trim() + if ($version) { + Write-Status 'azcmagent version' $version Green + Log "Agent version: $version" 'OK' + } + } + catch { + Write-Status 'azcmagent version' "Failed: $($_.Exception.Message)" Yellow + Log "Agent version read failed: $($_.Exception.Message)" 'WARN' + } + + try { + $cfg = (& $AzcmPath config list 2>$null | Out-String).Trim() + if ($cfg) { + $script:AgentConfigDump = $cfg + Write-Status 'Config list' 'Captured to log file' DarkGray + Add-Content -Path $LogFilePath -Value '' + Add-Content -Path $LogFilePath -Value '=================== AGENT CONFIG ===================' + Add-Content -Path $LogFilePath -Value $cfg + } + } + catch { + Write-Status 'Config list' "Failed: $($_.Exception.Message)" Yellow + } + + try { + $services = Get-Service himds, GCArcService, ExtensionService -ErrorAction SilentlyContinue + if ($services) { + foreach ($svc in $services) { + $color = if ($svc.Status -eq 'Running') { 'Green' } elseif ($svc.Status -eq 'Stopped') { 'Yellow' } else { 'DarkYellow' } + Write-Status ("svc/$($svc.Name)") ("$($svc.Status) | StartType=$($svc.StartType)") $color + if ($svc.Status -ne 'Running') { + Add-Issue -Severity 'HIGH' -Category 'AgentService' -Message "Service $($svc.Name) is $($svc.Status)" -Fix 'Start or restart the service and re-run the check.' + } + } + } + } + catch { + Write-Status 'Services' "Failed: $($_.Exception.Message)" Yellow + } + + $himdsLog = Join-Path $env:ProgramData 'AzureConnectedMachineAgent\Log\himds.log' + if (Test-Path $himdsLog) { + Write-Status 'himds.log' $himdsLog DarkGray + Add-Content -Path $LogFilePath -Value '' + Add-Content -Path $LogFilePath -Value '=================== HIMDS LOG TAIL ===================' + try { + Get-Content -Path $himdsLog -Tail 20 | Add-Content -Path $LogFilePath + } + catch { + Add-Content -Path $LogFilePath -Value "Could not read himds.log: $($_.Exception.Message)" + } + } +} + +function Detect-Platform { + param() + + if ($Platform -ne 'Auto') { + return $Platform + } + + try { + $resp = Invoke-RestMethod -Uri 'http://169.254.169.253:80/metadata/attested/document?api-version=2018-10-01' -Headers @{ Metadata = 'true' } -TimeoutSec 3 -ErrorAction Stop + if ($resp) { return 'AzureLocal' } + } + catch { } + + try { + $resp = Invoke-RestMethod -Uri 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' -Headers @{ Metadata = 'true' } -TimeoutSec 3 -ErrorAction Stop + if ($resp) { return 'AzureVM' } + } + catch { } + + return 'Arc' +} + +function Run-PlatformChecks { + param([string]$DetectedPlatform) + + Write-Section 'Platform Checks' + Write-Status 'Platform' $DetectedPlatform White + + switch ($DetectedPlatform) { + 'AzureLocal' { + try { + $att = Invoke-RestMethod -Uri 'http://169.254.169.253:80/metadata/attested/document?api-version=2018-10-01' -Headers @{ Metadata = 'true' } -TimeoutSec 5 -ErrorAction Stop + Write-Status 'Azure Local IMDS' 'Attestation endpoint reachable' Green + Log 'Azure Local attestation endpoint reachable' 'OK' + } + catch { + Write-Status 'Azure Local IMDS' "Failed: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'WARN' -Category 'AzureLocal' -Message 'Azure Local attestation endpoint not reachable.' -Fix 'Validate Azure Local guest metadata routing if this machine is expected to run on Azure Local.' + } + } + 'AzureStackHub' { + try { + $ws = Invoke-RestMethod -Uri 'http://168.63.129.16/?comp=versions' -Method Get -TimeoutSec 5 -ErrorAction Stop + if ($ws) { + Write-Status 'WireServer' 'Reachable' Green + Log 'WireServer reachable' 'OK' + } + } + catch { + Write-Status 'WireServer' "Failed: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'WARN' -Category 'AzureStackHub' -Message 'WireServer not reachable from guest.' -Fix 'Validate host-agent / guest networking if this guest is expected to be on Azure Stack Hub.' + } + } + 'AzureVM' { + try { + $imds = Invoke-RestMethod -Uri 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' -Headers @{ Metadata = 'true' } -TimeoutSec 5 -ErrorAction Stop + if ($imds) { + Write-Status 'IMDS' 'Reachable' Green + Log 'Azure VM IMDS reachable' 'OK' + } + } + catch { + Write-Status 'IMDS' "Failed: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'WARN' -Category 'AzureVM' -Message 'IMDS not reachable.' -Fix 'Validate guest routing and local metadata access if this is expected to be an Azure VM.' + } + } + default { + Write-Status 'Platform extras' 'No platform-specific metadata probe required' DarkGray + } + } +} + +Write-Banner 'AZURE ARC ENDPOINT CHECK (REVISED)' Write-Status 'Host' $env:COMPUTERNAME Write-Status 'Time' (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -$script:EffectiveProxy = $null -$script:WinHttpProxy = $null -$script:WinHttpBypass = $null -$script:AgentJson = $null -$script:GatewayUrl = $null -$script:InstalledExts = @() -$script:AgentVersion = $null - -# Endpoints tunneled through Arc Gateway (per MS docs) -# These go: Agent -> localhost:40343 (Arc Proxy) -> Enterprise Proxy -> Gateway -> Target -# Direct TCP test to target is NOT meaningful for tunneled endpoints. -$script:GatewayTunneledPatterns = @( - '*.his.arc.azure.com' - '*.guestconfiguration.azure.com' - 'dc.services.visualstudio.com' - 'guestnotificationservice.azure.com' -) - $azcm = Get-AzcmagentPath $script:PreOnboarding = (-not $azcm) if ($azcm) { - try { $script:AgentJson = & $azcm show -j 2>$null | ConvertFrom-Json } catch { } + try { + $json = & $azcm show -j 2>$null | Out-String + if ($json) { + $script:AgentJson = $json | ConvertFrom-Json + Add-Content -Path $LogFilePath -Value '' + Add-Content -Path $LogFilePath -Value '=================== AGENT SHOW JSON ===================' + Add-Content -Path $LogFilePath -Value $json + } + } + catch { + Log "Could not parse azcmagent show -j: $($_.Exception.Message)" 'WARN' + } } -# --- Pre-onboarding warning --- -if ($script:PreOnboarding) { - Write-Host '' - Write-Host ' ** PRE-ONBOARDING MODE **' -ForegroundColor Yellow - Write-Host ' azcmagent not installed. Region, mode, and extensions' -ForegroundColor Yellow - Write-Host ' cannot be auto-detected. Use parameters to override:' -ForegroundColor Yellow - Write-Host ' -Region -Mode ' -ForegroundColor DarkYellow - Write-Host ' -CheckIncludeAll (tests ALL extension endpoints)' -ForegroundColor DarkYellow - Write-Host ' -ProxyUrl (if proxy is not yet in WinHTTP/env)' -ForegroundColor DarkYellow - Write-Host '' +if (-not $script:PreOnboarding -and $script:AgentJson) { + $agentStatus = if ($script:AgentJson.PSObject.Properties['status']) { [string]$script:AgentJson.status } else { '' } + $agentResourceId = $null + foreach ($propName in @('resourceId', 'id')) { + if ($script:AgentJson.PSObject.Properties[$propName]) { + $candidateValue = [string]$script:AgentJson.$propName + if (-not [string]::IsNullOrWhiteSpace($candidateValue)) { + $agentResourceId = $candidateValue + break + } + } + } + + $agentLooksOnboarded = (($agentStatus -in @('Connected', 'Disconnected')) -or -not [string]::IsNullOrWhiteSpace($agentResourceId)) + $script:PreOnboarding = (-not $agentLooksOnboarded) } if ($azcm -and $script:AgentJson) { - $agSt = if ($script:AgentJson.PSObject.Properties['status']) { $script:AgentJson.status } else { $null } - $agVer = if ($script:AgentJson.PSObject.Properties['agentVersion']) { $script:AgentJson.agentVersion } else { $null } - $script:AgentVersion = $agVer - $agParts = @('Installed') - if ($agSt) { $agParts += $agSt } - if ($agVer) { $agParts += "v$agVer" } - $agColor = if ($agSt -eq 'Connected') { 'Green' } elseif ($agSt -eq 'Disconnected') { 'Red' } else { 'Yellow' } - Write-Status 'Agent' ($agParts -join ' | ') $agColor + $statusParts = @('Installed') + if ($script:AgentJson.status) { $statusParts += $script:AgentJson.status } + if ($script:AgentJson.agentVersion) { $statusParts += "v$($script:AgentJson.agentVersion)" } + Write-Status 'Agent' ($statusParts -join ' | ') $(if ($script:AgentJson.status -eq 'Connected') { 'Green' } elseif ($script:AgentJson.status -eq 'Disconnected') { 'Red' } else { 'Yellow' }) } elseif ($azcm) { - Write-Status 'Agent' 'Installed (could not read status)' Yellow + Write-Status 'Agent' 'Installed (status unavailable)' Yellow } else { - Write-Status 'Agent' 'NOT INSTALLED (pre-onboarding)' Yellow + Write-Status 'Agent' 'NOT INSTALLED (pre-onboarding mode)' Yellow +} + +if (-not $SkipAgentHealth) { + Get-AgentHealth -AzcmPath $azcm } -# --- Region --- +$detectedPlatform = Detect-Platform +Write-Status 'Detected platform' $detectedPlatform DarkGray +if ($Platform -eq 'Auto') { $Platform = $detectedPlatform } +Write-Status 'Platform in use' $Platform White + if (-not $Region) { if ($script:AgentJson -and $script:AgentJson.location) { $Region = $script:AgentJson.location @@ -396,155 +890,56 @@ if (-not $Region) { } else { $Region = 'eastus2' - Write-Status 'Region' "$Region (default - use -Region to override)" Yellow + Write-Status 'Region' "$Region (default fallback)" Yellow + Add-Issue -Severity 'WARN' -Category 'Region' -Message 'Region could not be auto-detected; using eastus2 fallback.' -Fix 'Specify -Region explicitly for pre-onboarding or disconnected scenarios.' } } else { Write-Status 'Region' "$Region (specified)" White } -# --- Mode --- if ($Mode -eq 'Auto') { if ($script:AgentJson) { - # Check for Gateway mode - $gwUrl = if ($script:AgentJson.PSObject.Properties['gatewayUrl']) { $script:AgentJson.gatewayUrl } - elseif ($script:AgentJson.PSObject.Properties['gatewayurl']) { $script:AgentJson.gatewayurl } - else { $null } - $connType = if ($script:AgentJson.PSObject.Properties['connectionType']) { $script:AgentJson.connectionType } - elseif ($script:AgentJson.PSObject.Properties['connectiontype']) { $script:AgentJson.connectiontype } - else { $null } - $plsVal = if ($script:AgentJson.PSObject.Properties['privateLinkScope']) { $script:AgentJson.privateLinkScope } - elseif ($script:AgentJson.PSObject.Properties['privatelinkscope']) { $script:AgentJson.privatelinkscope } - else { $null } - - if ($gwUrl -or $connType -eq 'gateway') { + $gwUrl = $null + if ($script:AgentJson.PSObject.Properties['gatewayUrl']) { $gwUrl = $script:AgentJson.gatewayUrl } + elseif ($script:AgentJson.PSObject.Properties['gatewayurl']) { $gwUrl = $script:AgentJson.gatewayurl } + + $privateLinkScope = $null + if ($script:AgentJson.PSObject.Properties['privateLinkScope']) { $privateLinkScope = $script:AgentJson.privateLinkScope } + elseif ($script:AgentJson.PSObject.Properties['privatelinkscope']) { $privateLinkScope = $script:AgentJson.privatelinkscope } + + if ($gwUrl) { $Mode = 'Gateway' $script:GatewayUrl = $gwUrl } - elseif ($plsVal) { + elseif ($privateLinkScope) { $Mode = 'Private' } else { - # DNS heuristic fallback try { - $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop - $ip = ($dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1).IPAddress + $dnsLookup = Resolve-Endpoint -Endpoint 'gbl.his.arc.azure.com' + if ($dnsLookup.Error) { throw $dnsLookup.Error } + $ip = ($dnsLookup.Result | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1).IPAddress $Mode = if (Test-IsPrivateIp -Ip $ip) { 'Private' } else { 'Public' } } - catch { $Mode = 'Public' } - } - } - else { - # No agent — use DNS heuristic to detect Private Link - try { - $dns = Resolve-DnsName -Name 'gbl.his.arc.azure.com' -Type A -ErrorAction Stop - $ip = ($dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1).IPAddress - $Mode = if (Test-IsPrivateIp -Ip $ip) { 'Private' } else { 'Public' } - } - catch { $Mode = 'Public' } - } -} - -$modeColor = switch ($Mode) { - 'Private' { 'Magenta' } - 'Gateway' { 'DarkYellow' } - default { 'Green' } -} -Write-Status 'Mode' $Mode $modeColor - -# Apply -GatewayUrl parameter override (pre-onboarding) -if ($GatewayUrl -and -not $script:GatewayUrl) { - $script:GatewayUrl = $GatewayUrl - if ($Mode -eq 'Auto' -or $Mode -eq 'Public') { $Mode = 'Gateway' } -} - -if ($script:GatewayUrl) { - Write-Status 'Gateway' $script:GatewayUrl DarkYellow -} - -# --- Installed Extensions (auto-detect) --- -# Strategy: Read from plugin directory first (fast, non-disruptive). -# Fallback to 'azcmagent extension list' only if directory not found. -# Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-vm-extensions#general-troubleshooting -# Path: C:\Packages\Plugins\\\ -if (-not $SkipExtensions -and $azcm) { - $pluginDir = Join-Path $env:SystemDrive 'Packages\Plugins' - $detected = $false - - # --- Fast path: scan plugin directory names --- - if (Test-Path $pluginDir) { - $plugins = (Get-ChildItem -Path $pluginDir -Directory -ErrorAction SilentlyContinue).Name -join '|' - if ($plugins) { - $detected = $true - if ($plugins -match 'SqlServer|WindowsAgent\.SqlServer') { $script:InstalledExts += 'SQL' } - if ($plugins -match 'AzureMonitor|AzureMonitorWindowsAgent') { $script:InstalledExts += 'AMA' } - if ($plugins -match 'MDE|AzureDefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } - if ($plugins -match 'AdminCenter') { $script:InstalledExts += 'WAC' } - if ($plugins -match 'KeyVault') { $script:InstalledExts += 'KV' } - if ($plugins -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } - if ($plugins -match 'ChangeTracking') { $script:InstalledExts += 'CT' } - if ($plugins -match 'GuestAttestation|WindowsAttestation') { $script:InstalledExts += 'GA' } - if ($plugins -match 'WindowsPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } - if ($plugins -match 'CustomScript|RunCommand') { $script:InstalledExts += 'CS' } - if ($plugins -match 'DependencyAgent') { $script:InstalledExts += 'DA' } - if ($plugins -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } - } - } - - # --- Fallback: azcmagent extension list (slower, stops Extension Service) --- - if (-not $detected) { - try { - $extOut = & $azcm extension list 2>$null - if ($extOut) { - $extLines = $extOut | Out-String - if ($extLines -match 'WindowsAgent\.SqlServer|SqlServer') { $script:InstalledExts += 'SQL' } - if ($extLines -match 'AzureMonitor|AMA') { $script:InstalledExts += 'AMA' } - if ($extLines -match 'MDE|DefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } - if ($extLines -match 'AdminCenter') { $script:InstalledExts += 'WAC' } - if ($extLines -match 'KeyVault') { $script:InstalledExts += 'KV' } - if ($extLines -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } - if ($extLines -match 'ChangeTracking') { $script:InstalledExts += 'CT' } - if ($extLines -match 'GuestAttestation|WindowsAttestation') { $script:InstalledExts += 'GA' } - if ($extLines -match 'WindowsPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } - if ($extLines -match 'CustomScript|RunCommand') { $script:InstalledExts += 'CS' } - if ($extLines -match 'DependencyAgent') { $script:InstalledExts += 'DA' } - if ($extLines -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } + catch { + $Mode = 'Public' } } - catch { } - } -} - -if ($script:InstalledExts.Count -gt 0) { - Write-Status 'Extensions' ($script:InstalledExts -join ', ') White -} -else { - if ($script:PreOnboarding) { - if ($CheckIncludeAll) { - Write-Status 'Extensions' '(all - pre-onboarding with -CheckIncludeAll)' DarkYellow - } - else { - Write-Status 'Extensions' '(none - use -CheckIncludeAll to test all)' Yellow - } } else { - Write-Status 'Extensions' '(none detected)' DarkGray + $Mode = 'Public' } } +Write-Status 'Mode' $Mode $(switch ($Mode) { 'Private' { 'Magenta' } 'Gateway' { 'DarkYellow' } default { 'Green' } }) -# ========================================================================= -# PHASE 2: PROXY CHAIN (WinHTTP -> Agent -> Env -> Gateway) -# ========================================================================= - -Write-Section 'Proxy Chain (WinHTTP -> Agent -> HTTPS_PROXY -> Gateway)' +Write-Section 'Proxy Configuration' -# --- WinHTTP --- try { $wh = netsh winhttp show proxy 2>$null | Out-String if ($wh -match 'Proxy Server\(s\)\s*:\s*(.+)|Servidor\(es\) Proxy\s*:\s*(.+)') { $script:WinHttpProxy = ($Matches[1], $Matches[2] | Where-Object { $_ } | Select-Object -First 1).Trim() } - # Generic URL fallback for unrecognized locales (FR, DE, ES, JP, etc.) if (-not $script:WinHttpProxy -and $wh -notmatch 'Direct|direct|Direto|direto|Direkt' -and $wh -match '(https?://[^\s;]+)') { $script:WinHttpProxy = $Matches[1].Trim() } @@ -554,754 +949,583 @@ try { } catch { } -# --- Agent proxy --- $agentProxy = $null +$agentRuntimeProxy = $null +$agentUpstreamProxy = $null $agentBypass = $null +$hasAgentBypass = $false if ($azcm) { try { - $raw = & $azcm config get proxy.url 2>$null | Out-String - $raw = $raw.Trim() - if (Test-IsValidProxyUri $raw) { $agentProxy = $raw } + $rawProxy = (& $azcm config get proxy.url 2>$null | Out-String).Trim() + if (Test-IsValidProxyUri $rawProxy) { $agentProxy = $rawProxy } $agentBypass = (& $azcm config get proxy.bypass 2>$null | Out-String).Trim() + if ($agentBypass -and $agentBypass -notmatch '^\[\s*\]$') { + $hasAgentBypass = $true + } } catch { } } +if ($script:AgentJson) { + if ($script:AgentJson.PSObject.Properties['httpsProxy']) { + $runtimeProxyCandidate = [string]$script:AgentJson.httpsProxy + if (Test-IsValidProxyUri $runtimeProxyCandidate) { $agentRuntimeProxy = $runtimeProxyCandidate } + } + if ($script:AgentJson.PSObject.Properties['upstreamProxy']) { + $upstreamProxyCandidate = [string]$script:AgentJson.upstreamProxy + if (Test-IsValidProxyUri $upstreamProxyCandidate) { $agentUpstreamProxy = $upstreamProxyCandidate } + } +} -# --- Env vars --- -$envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') +$envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Machine') if (-not $envProxy) { $envProxy = [Environment]::GetEnvironmentVariable('HTTPS_PROXY', 'Process') } $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Machine') if (-not $envNoProxy) { $envNoProxy = [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process') } -# --- Effective proxy (precedence: -ProxyUrl > azcmagent > HTTPS_PROXY > WinHTTP) --- if ($ProxyUrl -and (Test-IsValidProxyUri $ProxyUrl)) { $script:EffectiveProxy = $ProxyUrl } +elseif ($agentRuntimeProxy) { + $script:EffectiveProxy = $agentRuntimeProxy +} elseif ($agentProxy) { $script:EffectiveProxy = $agentProxy } elseif ($envProxy -and (Test-IsValidProxyUri $envProxy)) { $script:EffectiveProxy = $envProxy } -elseif ($script:PreOnboarding -and $script:WinHttpProxy -and (Test-IsValidProxyUri "http://$($script:WinHttpProxy)")) { - # Pre-onboarding: no agent proxy, fallback to WinHTTP if configured - $whUri = if ($script:WinHttpProxy -match '^https?://') { $script:WinHttpProxy } else { "http://$($script:WinHttpProxy)" } - if (Test-IsValidProxyUri $whUri) { $script:EffectiveProxy = $whUri } -} -# --- Upstream proxy (Gateway mode) --- -$upstreamProxy = $null -if ($script:AgentJson) { - $upstreamProxy = if ($script:AgentJson.PSObject.Properties['upstreamProxy']) { $script:AgentJson.upstreamProxy } - elseif ($script:AgentJson.PSObject.Properties['upstreamproxy']) { $script:AgentJson.upstreamproxy } - else { $null } -} +$script:ProxyMode = if ($script:EffectiveProxy) { 'ExplicitProxy' } else { 'Direct' } -# --- Display (pipe-delimited like azcmagent check) --- $pFmt = " {0,-18} | {1,-35} | {2,-22}" Write-Host ($pFmt -f 'Source', 'Proxy', 'Used By') -ForegroundColor Cyan Write-Host (" {0,-18}-+-{1,-35}-+-{2,-22}" -f ('-' * 18), ('-' * 35), ('-' * 22)) -ForegroundColor DarkGray - -$agentProxyLabel = if ($script:PreOnboarding) { 'N/A (not installed)' } elseif ($agentProxy) { $agentProxy } else { '(not set)' } -$rows = @( - , @('WinHTTP (OS)', $(if ($script:WinHttpProxy) { $script:WinHttpProxy } else { 'Direct' }), 'SCHANNEL/OCSP/CRL') - , @('azcmagent', $agentProxyLabel, 'Arc Agent') - , @('HTTPS_PROXY', $(if ($envProxy) { $envProxy } else { '(not set)' }), 'Extensions') -) -if ($upstreamProxy) { - $rows += , @('Upstream Proxy', $upstreamProxy, 'Gateway chain') +foreach ($row in @( + @('WinHTTP (OS)', $(if ($script:WinHttpProxy) { $script:WinHttpProxy } else { 'Direct' }), 'SCHANNEL/OCSP/CRL'), + @('azcmagent rt', $(if ($agentRuntimeProxy) { $agentRuntimeProxy } else { '(not set)' }), 'Arc runtime path'), + @('azcmagent cfg', $(if ($agentProxy) { $agentProxy } else { '(not set)' }), 'Configured upstream'), + @('upstreamProxy', $(if ($agentUpstreamProxy) { $agentUpstreamProxy } else { '(not set)' }), 'Gateway upstream'), + @('HTTPS_PROXY', $(if ($envProxy) { $envProxy } else { '(not set)' }), 'Extensions') +)) { + $color = if ($row[1] -match 'Direct|not set') { 'DarkGray' } else { 'White' } + Write-Host ($pFmt -f $row[0], $row[1], $row[2]) -ForegroundColor $color } -foreach ($r in $rows) { - $c = if ($r[1] -match 'not set|Direct|N/A') { 'DarkGray' } else { 'White' } - Write-Host ($pFmt -f $r[0], $r[1], $r[2]) -ForegroundColor $c -} -if ($script:EffectiveProxy) { - Write-Host '' - Write-Status 'Effective proxy' $script:EffectiveProxy Green +Write-Status 'Effective proxy' $(if ($script:EffectiveProxy) { $script:EffectiveProxy } else { 'Direct' }) $(if ($script:EffectiveProxy) { 'Green' } else { 'DarkGray' }) +Write-Status 'Proxy mode' $script:ProxyMode $(if ($script:ProxyMode -eq 'ExplicitProxy') { 'Green' } else { 'DarkGray' }) + +if ($Mode -eq 'Gateway' -and $hasAgentBypass) { + Write-Status 'Gateway bypass' 'Configured categories present, but Arc Gateway does not support proxy bypass' Yellow + Add-Issue -Severity 'INFO' -Category 'Gateway' -Message 'proxy.bypass is configured, but Azure Arc Gateway does not support proxy bypass. Treat bypass settings as non-effective for the Arc agent path in gateway mode.' -Fix 'Validate the gateway path without relying on proxy bypass semantics.' } -# --- Proxy flow explanation (shown when proxy is configured or PLS/Gateway active) --- -if ($script:EffectiveProxy -or $script:WinHttpProxy -or $Mode -in 'Private', 'Gateway') { - Write-Host '' - Write-Host ' Traffic flow per component:' -ForegroundColor DarkGray - if ($Mode -eq 'Gateway') { - Write-Host ' Arc Agent (tunneled) : Agent -> localhost:40343 -> Upstream Proxy -> Gateway -> Target' -ForegroundColor DarkGray - Write-Host ' Arc Agent (direct) : Agent -> Enterprise Proxy -> Target' -ForegroundColor DarkGray - } - elseif ($Mode -eq 'Private') { - Write-Host ' Arc Agent (PLS) : Agent -> Private Endpoint (VNET) -> Target (private IP)' -ForegroundColor DarkGray - Write-Host ' Arc Agent (non-PLS) : Agent -> Proxy (if set) -> Target (public IP)' -ForegroundColor DarkGray - } - else { - Write-Host ' Arc Agent : Agent -> azcmagent proxy.url -> Target' -ForegroundColor DarkGray +if ($script:EffectiveProxy) { + try { + $pxUri = [System.Uri]$script:EffectiveProxy + $script:EffectiveProxyReachable = Test-TcpPort -HostName $pxUri.Host -Port $pxUri.Port -TimeoutMs 4000 + Write-Status 'Proxy reachability' ("$($pxUri.Host):$($pxUri.Port) => " + $(if ($script:EffectiveProxyReachable) { 'Reachable' } else { 'Unreachable' })) $(if ($script:EffectiveProxyReachable) { 'Green' } else { 'Red' }) } - Write-Host ' Extensions : Extension -> HTTPS_PROXY -> Target' -ForegroundColor DarkGray - Write-Host ' SCHANNEL (PKI/CRL) : OS -> WinHTTP proxy (unless endpoint in bypass) -> Target' -ForegroundColor DarkGray - Write-Host ' TCP test (this script): Direct to Target:443 (no proxy - validates L3/L4)' -ForegroundColor DarkGray - Write-Host ' HTTP test (this scrpt): Via effective proxy (validates L7 app-layer path)' -ForegroundColor DarkGray - if ($Mode -eq 'Private') { - Write-Host ' DNS (Private Link) : PLS endpoints resolve to private IP (validated in DNS test)' -ForegroundColor DarkGray + catch { + $script:EffectiveProxyParseError = $_.Exception.Message + Add-Issue -Severity 'HIGH' -Category 'Proxy' -Message 'Effective proxy URI is not parseable.' -Fix 'Validate -ProxyUrl or local proxy configuration.' } } -# --- Gateway + proxy.bypass warning --- -if ($Mode -eq 'Gateway' -and $agentBypass) { - Add-Issue -Sev 'WARN' -Cat 'Gateway' ` - -Msg 'proxy.bypass is configured but NOT supported in Gateway mode' ` - -Fix 'Run: azcmagent config clear proxy.bypass' -} - -# Neutralize .NET DefaultWebProxy for PS 5.1 when no proxy -if (-not $script:EffectiveProxy -and $PSVersionTable.PSVersion.Major -lt 6) { - try { [System.Net.WebRequest]::DefaultWebProxy = $null } catch { } -} - -Log "Region=$Region Mode=$Mode Proxy=$($script:EffectiveProxy) Gateway=$($script:GatewayUrl)" Info -NoCount - -# ========================================================================= -# PHASE 3: TLS & CRYPTO VALIDATION -# ========================================================================= - -Write-Section 'TLS & Crypto Validation' -# Azure Arc requires TLS 1.2 or 1.3 ONLY. -# Required cipher suites: -# TLS 1.3: TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256 -# TLS 1.2: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 -# SQL Arc endpoints (*.arcdataservices.com) require TLS 1.2/1.3 — Server 2012 (non-R2) NOT supported. -# Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues - -$tlsOk = $false +Write-Section 'TLS Validation' +$handshakeOk = $false +$tls12Disabled = $false +$cipherWarning = $false +$cipherInventorySupported = $false +$cipherInventoryError = $null +$strongCrypto = $false +$legacyTls12OnlyOs = $false try { - $osVer = [System.Environment]::OSVersion.Version - $osBuild = $osVer.Build - $osCaption = (Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption - if (-not $osCaption) { $osCaption = "Windows $($osVer.Major).$($osVer.Minor) Build $osBuild" } + $os = Get-OperatingSystemInfo + $osCaption = if ($os -and $os.Caption) { $os.Caption } else { "Windows $([System.Environment]::OSVersion.Version)" } + $legacyTls12OnlyOs = Test-IsLegacyTls12OnlyOs -OsInfo $os Write-Status 'OS' $osCaption DarkGray + if ($legacyTls12OnlyOs) { + Write-Status 'TLS posture' 'Legacy OS detected; TLS 1.2 is the required baseline and TLS 1.3 observations are informational.' DarkGray + } - # --- 1. SCHANNEL Registry Check --- $schBase = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols' - $tls12Disabled = $false $tls12Path = "$schBase\TLS 1.2\Client" if (Test-Path $tls12Path) { - $enVal = (Get-ItemProperty -Path $tls12Path -Name 'Enabled' -EA SilentlyContinue).Enabled - $dbVal = (Get-ItemProperty -Path $tls12Path -Name 'DisabledByDefault' -EA SilentlyContinue).DisabledByDefault - if ($enVal -eq 0) { $tls12Disabled = $true } - if ($dbVal -eq 1 -and $enVal -ne 1) { $tls12Disabled = $true } - } - - # TLS 1.3 support (Server 2022+ / Build 20348+) - $has13 = $false - $tls13Path = "$schBase\TLS 1.3\Client" - if (Test-Path $tls13Path) { - $en13 = (Get-ItemProperty -Path $tls13Path -Name 'Enabled' -EA SilentlyContinue).Enabled - if ($en13 -ne 0) { $has13 = $true } + $enabled = (Get-ItemProperty -Path $tls12Path -Name 'Enabled' -ErrorAction SilentlyContinue).Enabled + $disabledByDefault = (Get-ItemProperty -Path $tls12Path -Name 'DisabledByDefault' -ErrorAction SilentlyContinue).DisabledByDefault + if ($enabled -eq 0 -or ($disabledByDefault -eq 1 -and $enabled -ne 1)) { + $tls12Disabled = $true + } } - if ($osVer.Major -ge 10 -and $osBuild -ge 20348) { $has13 = $true } - # OS era check - $isServer2012NonR2 = ($osVer.Major -eq 6 -and $osVer.Minor -eq 2) # 6.2 = Server 2012 / Win8 - $isModernOS = ($osVer.Major -gt 6) -or ($osVer.Major -eq 6 -and $osVer.Minor -ge 3) # 6.3+ = 2012R2+ - - # --- 2. Real TLS 1.2 Handshake Test --- - $tlsHandshakeOk = $false - $negotiatedProto = '' $savedProto = [System.Net.ServicePointManager]::SecurityProtocol + $tlsProbeEndpoint = 'login.microsoftonline.com' + $tlsProbeBypassed = $false + $tlsUsesProxy = $false + $tlsBypassedEndpoints = Get-AgentBypassedEndpoints -AgentBypass $agentBypass -Region $Region + foreach ($entry in $tlsBypassedEndpoints) { + if (-not $entry) { continue } + $norm = $entry.Trim().ToLower() + if (-not $norm) { continue } + if ($norm.StartsWith('*.')) { $norm = $norm.Substring(1) } + $probeHost = $tlsProbeEndpoint.Trim().ToLower() + if ($probeHost -eq $norm.TrimStart('.') -or ($norm.StartsWith('.') -and $probeHost.EndsWith($norm))) { + $tlsProbeBypassed = $true + break + } + } + if ($tlsProbeBypassed) { + Write-Status 'TLS probe path' 'AAD bypass applied; probing direct path to login.microsoftonline.com' DarkGray + } try { - # Force .NET to use TLS 1.2 for this test [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - $testReq = [System.Net.HttpWebRequest]::Create('https://login.microsoftonline.com') - $testReq.Timeout = 10000 - $testReq.Method = 'HEAD' - if ($script:EffectiveProxy) { - $testReq.Proxy = [System.Net.WebProxy]::new($script:EffectiveProxy) - $testReq.Proxy.UseDefaultCredentials = $true - } elseif ($PSVersionTable.PSVersion.Major -lt 6) { - $testReq.Proxy = $null - } - $testResp = $testReq.GetResponse() - $testResp.Close() - $tlsHandshakeOk = $true - $negotiatedProto = 'TLS 1.2' + $request = [System.Net.HttpWebRequest]::Create("https://$tlsProbeEndpoint") + $request.Method = 'HEAD' + $request.Timeout = 10000 + if ($script:EffectiveProxy -and -not $tlsProbeBypassed) { + $request.Proxy = New-Object System.Net.WebProxy($script:EffectiveProxy) + $request.Proxy.UseDefaultCredentials = $true + $tlsUsesProxy = $true + } + else { + $request.Proxy = $null + } + $response = $request.GetResponse() + $response.Close() + $handshakeOk = $true } catch { - # If TLS 1.2 fails, the OS may not support it + $handshakeOk = $false } finally { [System.Net.ServicePointManager]::SecurityProtocol = $savedProto } - # --- 3. Cipher Suite Check --- - $requiredCiphers12 = @( - 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384' + foreach ($regPath in 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319') { + if (Test-Path $regPath) { + $sc = (Get-ItemProperty -Path $regPath -Name 'SchUseStrongCrypto' -ErrorAction SilentlyContinue).SchUseStrongCrypto + if ($sc -eq 1) { $strongCrypto = $true } + } + } + + $requiredGcm = @( + 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256' ) - $requiredCiphers13 = @( - 'TLS_AES_256_GCM_SHA384' - 'TLS_AES_128_GCM_SHA256' - ) - $cipherOk = $true - $missingCiphers = @() - try { - $sysCiphers = (Get-TlsCipherSuite -ErrorAction SilentlyContinue).Name - if ($sysCiphers) { - foreach ($rc in $requiredCiphers12) { - if ($sysCiphers -notcontains $rc) { $missingCiphers += $rc; $cipherOk = $false } + $cipherInventory = Get-TlsCipherInventory + $cipherInventorySupported = $cipherInventory.Supported + $cipherInventoryError = $cipherInventory.Error + if ($cipherInventorySupported -and $cipherInventory.Names.Count -gt 0) { + foreach ($cipher in $requiredGcm) { + if ($cipherInventory.Names -notcontains $cipher) { + $cipherWarning = $true + break } } - # Get-TlsCipherSuite may not exist on older OS (Server 2012/2012R2) - } - catch { - # Get-TlsCipherSuite not available — skip cipher check (older OS) - $cipherOk = $true - } - - # --- 4. .NET Strong Crypto --- - $strongCrypto = $false - $sysDefaultTls = $false - $regPath64 = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' - $regPath32 = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' - foreach ($rp in @($regPath64, $regPath32)) { - if (Test-Path $rp) { - $sc = (Get-ItemProperty -Path $rp -Name 'SchUseStrongCrypto' -EA SilentlyContinue).SchUseStrongCrypto - if ($sc -eq 1) { $strongCrypto = $true } - $sd = (Get-ItemProperty -Path $rp -Name 'SystemDefaultTlsVersions' -EA SilentlyContinue).SystemDefaultTlsVersions - if ($sd -eq 1) { $sysDefaultTls = $true } - } } - # --- Display Results --- if ($tls12Disabled) { - Write-Status 'SCHANNEL TLS 1.2' 'DISABLED in registry' Red - Add-Issue -Sev 'CRITICAL' -Cat 'TLS' ` - -Msg 'TLS 1.2 is disabled in SCHANNEL registry. Azure Arc requires TLS 1.2+.' ` - -Fix 'https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' + Write-Status 'TLS 1.2' 'Disabled in SCHANNEL' Red + Add-Issue -Severity 'CRITICAL' -Category 'TLS' -Message 'TLS 1.2 is disabled in SCHANNEL.' -Fix 'Enable TLS 1.2 in SCHANNEL and re-run the check.' } - elseif ($tlsHandshakeOk) { - $tlsLabel = if ($has13) { 'TLS 1.2 + 1.3' } else { 'TLS 1.2' } - Write-Status 'TLS Handshake' "$tlsLabel verified (live test passed)" Green - $tlsOk = $true - } - elseif ($isModernOS) { - Write-Status 'TLS SCHANNEL' 'TLS 1.2 enabled (OS default, handshake test failed)' Yellow - $tlsOk = $true + elseif ($handshakeOk) { + Write-Status 'TLS handshake' 'TLS 1.2 handshake succeeded' Green } else { - Write-Status 'TLS' 'Could not verify TLS 1.2 - check SCHANNEL config' Yellow - Add-Issue -Sev 'HIGH' -Cat 'TLS' ` - -Msg 'Cannot verify TLS 1.2 support. Azure Arc requires TLS 1.2+.' ` - -Fix 'https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' - } - - # Cipher suites - if (-not $cipherOk -and $missingCiphers.Count -gt 0) { - Write-Status 'Cipher Suites' "MISSING: $($missingCiphers -join ', ')" Red - Add-Issue -Sev 'HIGH' -Cat 'TLS Ciphers' ` - -Msg "Required cipher suites missing: $($missingCiphers -join ', ')" ` - -Fix 'Enable GCM cipher suites via Group Policy or PowerShell Enable-TlsCipherSuite' - } - elseif ($missingCiphers.Count -eq 0 -and $cipherOk) { - Write-Status 'Cipher Suites' 'Required GCM suites present' Green - } - - # .NET StrongCrypto + SystemDefaultTlsVersions - if ($strongCrypto -and $sysDefaultTls) { - Write-Status '.NET TLS Config' 'SchUseStrongCrypto=1, SystemDefaultTlsVersions=1' Green - } - elseif ($strongCrypto) { - Write-Status '.NET StrongCrypto' 'Enabled (SystemDefaultTlsVersions NOT set)' Yellow - } - elseif ($sysDefaultTls) { - Write-Status '.NET TLS Config' 'SystemDefaultTlsVersions=1 (SchUseStrongCrypto NOT set)' Yellow - } - else { - # On Server 2016+ (.NET 4.6+), TLS 1.2 is default even without these keys (INFO only) - # On Server 2012 R2, this is a blocking issue (WARN) - $netSev = if ($isModernOS -and $osVer.Major -ge 10) { 'INFO' } else { 'WARN' } - $netMsg = if ($netSev -eq 'INFO') { - '.NET TLS keys not set (OK on this OS - .NET 4.6+ defaults to TLS 1.2)' - } else { - '.NET Framework not configured for TLS 1.2 default. Extensions may fail with: "Could not create SSL/TLS secure channel"' + $tlsMessage = 'Live TLS 1.2 handshake to login.microsoftonline.com failed.' + $tlsFix = 'Validate outbound TLS inspection, proxy behavior, SCHANNEL policy, and root trust.' + $tlsSeverity = 'HIGH' + if ($tlsUsesProxy) { + $tlsSeverity = 'WARN' + $tlsMessage = 'Live TLS 1.2 handshake failed through the configured proxy path.' + $tlsFix = 'Validate proxy CONNECT policy or TLS inspection before treating this as an OS TLS problem.' } - Write-Status '.NET TLS Config' 'Neither StrongCrypto nor SystemDefaultTlsVersions set' $(if ($netSev -eq 'INFO') { 'DarkGray' } else { 'Yellow' }) - if ($netSev -eq 'WARN') { - Add-Issue -Sev 'WARN' -Cat 'TLS .NET' ` - -Msg $netMsg ` - -Fix 'Set SchUseStrongCrypto=1 and SystemDefaultTlsVersions=1 in HKLM:\SOFTWARE\[Wow6432Node\]Microsoft\.NETFramework\v4.0.30319' + elseif ($legacyTls12OnlyOs) { + $tlsSeverity = 'WARN' + $tlsMessage = 'Live TLS 1.2 handshake failed on a legacy TLS-1.2-only OS.' + $tlsFix = 'If azcmagent still reports core healthy, treat this as an inspection/path warning and validate SCHANNEL, root trust, and outbound filtering.' } - } - # Server 2012 (non-R2) + SQL Arc warning - if ($isServer2012NonR2 -and ($script:InstalledExts -contains 'SQL' -or $CheckIncludeAll)) { - Write-Status 'SQL Arc TLS' 'Server 2012 (non-R2) NOT supported for SQL Arc telemetry' Red - Add-Issue -Sev 'HIGH' -Cat 'SQL TLS' ` - -Msg 'Windows Server 2012 (non-R2) does not support TLS 1.2 for *.arcdataservices.com endpoints.' ` - -Fix 'Upgrade to Server 2012 R2+ for SQL Server enabled by Azure Arc.' - } -} -catch { - Write-Status 'TLS' "Check error: $($_.Exception.Message)" Yellow - $tlsOk = $true -} -Log "TLS check: OK=$tlsOk handshake=$tlsHandshakeOk ciphers=$cipherOk strongCrypto=$strongCrypto sysDefaultTls=$sysDefaultTls" $(if ($tlsOk) { 'OK' } else { 'Fail' }) - -# --- TLS Registry Dump (full diagnostic view) --- -Write-Section 'TLS Registry Dump (Actual vs Recommended)' - -$tlsRegChecks = @( - @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'; Name = 'Enabled'; Recommended = 1; Scope = 'TLS Client'; UsedBy = 'Arc Agent, OCSP/CRL' } - @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'; Name = 'DisabledByDefault'; Recommended = 0; Scope = 'TLS Client'; UsedBy = 'Arc Agent, OCSP/CRL' } - @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'; Name = 'Enabled'; Recommended = 1; Scope = 'TLS Server'; UsedBy = 'WAC inbound, RDP' } - @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'; Name = 'DisabledByDefault'; Recommended = 0; Scope = 'TLS Server'; UsedBy = 'WAC inbound, RDP' } - @{ Path = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'; Name = 'SchUseStrongCrypto'; Recommended = 1; Scope = '.NET x64'; UsedBy = 'PS 5.1, Extensions' } - @{ Path = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'; Name = 'SystemDefaultTlsVersions'; Recommended = 1; Scope = '.NET x64'; UsedBy = 'PS 5.1, Extensions' } - @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'; Name = 'SchUseStrongCrypto'; Recommended = 1; Scope = '.NET x86'; UsedBy = '32-bit .NET apps' } - @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'; Name = 'SystemDefaultTlsVersions'; Recommended = 1; Scope = '.NET x86'; UsedBy = '32-bit .NET apps' } -) - -$rdFmt = " {0,-6} | {1,-10} | {2,-24} | {3,-9} | {4,-5} | {5}" -Write-Host ($rdFmt -f 'Status', 'Scope', 'Name', 'Value', 'Rec.', 'Used By') -ForegroundColor Cyan -Write-Host (" {0,-6}-+-{1,-10}-+-{2,-24}-+-{3,-9}-+-{4,-5}-+-{5}" -f ('-' * 6), ('-' * 10), ('-' * 24), ('-' * 9), ('-' * 5), ('-' * 22)) -ForegroundColor DarkGray - -$tlsRegIssues = 0 -foreach ($chk in $tlsRegChecks) { - $val = $null - $valStr = 'N/A' - if (Test-Path $chk.Path) { - $prop = Get-ItemProperty -Path $chk.Path -Name $chk.Name -ErrorAction SilentlyContinue - if ($null -ne $prop -and $null -ne $prop.($chk.Name)) { - $val = $prop.($chk.Name) - $valStr = "$val" + Write-Status 'TLS handshake' 'Live TLS 1.2 handshake failed' Yellow + $script:DeferredTlsIssue = [pscustomobject]@{ + Severity = $tlsSeverity + Message = $tlsMessage + Fix = $tlsFix + UsesProxy = $tlsUsesProxy } - else { - $valStr = '(not set)' - } - } - else { - $valStr = '(key missing)' } - # Determine status - $recStr = "$($chk.Recommended)" - if ($val -eq $chk.Recommended) { - $status = 'OK' - $color = 'Green' + if (-not $cipherInventorySupported) { + Write-Status 'Cipher suites' 'Local cipher inventory unavailable on this PowerShell/OS; skipped' DarkGray } - elseif ($null -eq $val -and $chk.Scope -like 'TLS *') { - # SCHANNEL keys not set = OS default (TLS 1.2 enabled on 2012R2+ with KB, 2016+ native) - $status = 'DFLT' - $color = 'DarkYellow' + elseif ($cipherWarning) { + Write-Status 'Cipher suites' 'GCM suite set appears reduced' Yellow + Add-Issue -Severity 'WARN' -Category 'TLS Ciphers' -Message 'Expected GCM cipher suites were not fully observed in the local inventory.' -Fix 'Review local cipher policy if Arc TLS negotiation still fails.' } else { - $status = 'WARN' - $color = 'Yellow' - $tlsRegIssues++ + Write-Status 'Cipher suites' 'No blocking issue detected' Green } - # Shorten path for display - $shortPath = $chk.Path -replace 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\SecurityProviders\\SCHANNEL\\Protocols\\', '...\SCHANNEL\' ` - -replace 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\', '...\WOW6432Node\' ` - -replace 'HKLM:\\SOFTWARE\\Microsoft\\', '...\Microsoft\' - - $usedBy = if ($chk.UsedBy) { $chk.UsedBy } else { '-' } - Write-Host ($rdFmt -f $status, $chk.Scope, $chk.Name, $valStr, $recStr, $usedBy) -ForegroundColor $color -} - -if ($tlsRegIssues -gt 0) { - Write-Host '' - Write-Host " $tlsRegIssues registry value(s) not matching recommendation." -ForegroundColor Yellow - Write-Host ' Ref: https://learn.microsoft.com/azure/azure-arc/servers/troubleshoot-networking#windows-tls-configuration-issues' -ForegroundColor DarkCyan - Write-Host ' Ref: https://learn.microsoft.com/entra/identity/hybrid/connect/reference-connect-tls-enforcement' -ForegroundColor DarkCyan + Write-Status '.NET StrongCrypto' $(if ($strongCrypto) { 'Enabled' } else { 'Not set' }) $(if ($strongCrypto) { 'Green' } else { 'Yellow' }) } -else { - Write-Host '' - Write-Host ' All TLS registry values match Azure Arc recommendations.' -ForegroundColor Green +catch { + Write-Status 'TLS' "Validation error: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'WARN' -Category 'TLS' -Message 'TLS validation encountered an error.' -Fix 'Review PowerShell / .NET capabilities on this OS and validate TLS manually if needed.' } -Write-Host '' -Write-Host ' Legend: OK=value matches | DFLT=not set but OS default is correct (Server 2016+) | WARN=should be configured' -ForegroundColor DarkGray -# Also log to file -$tlsRegLog = $tlsRegChecks | ForEach-Object { - $v = $null - if (Test-Path $_.Path) { $v = (Get-ItemProperty -Path $_.Path -Name $_.Name -EA SilentlyContinue).($_.Name) } - "$($_.Scope) | $($_.Name) = $(if ($null -ne $v) { $v } else { 'N/A' }) (rec: $($_.Recommended))" +if (-not $SkipExtensions -and $azcm) { + try { + $extOut = & $azcm extension list 2>$null | Out-String + if ($extOut) { + if ($extOut -match 'WindowsAgent\.SqlServer|LinuxAgent\.SqlServer|SqlServer') { $script:InstalledExts += 'SQL' } + if ($extOut -match 'AzureMonitor|AMA') { $script:InstalledExts += 'AMA' } + if ($extOut -match 'MDE|DefenderForServers|AzureDefender') { $script:InstalledExts += 'MDE' } + if ($extOut -match 'AdminCenter') { $script:InstalledExts += 'WAC' } + if ($extOut -match 'KeyVault') { $script:InstalledExts += 'KV' } + if ($extOut -match 'HybridWorker|Automation') { $script:InstalledExts += 'HRW' } + if ($extOut -match 'ChangeTracking') { $script:InstalledExts += 'CT' } + if ($extOut -match 'GuestAttestation|WindowsAttestation|LinuxAttestation') { $script:InstalledExts += 'GA' } + if ($extOut -match 'WindowsPatchExtension|LinuxPatchExtension|UpdateManagement') { $script:InstalledExts += 'UM' } + if ($extOut -match 'CustomScript') { $script:InstalledExts += 'CS' } + if ($extOut -match 'DependencyAgent') { $script:InstalledExts += 'DA' } + if ($extOut -match 'DefenderForSQL|AdvancedThreatProtection|MicrosoftDefenderForSQL') { $script:InstalledExts += 'DSQL' } + $script:InstalledExts = $script:InstalledExts | Select-Object -Unique + } + } + catch { + Add-Issue -Severity 'WARN' -Category 'Extensions' -Message 'Could not enumerate installed Arc extensions.' -Fix 'Verify azcmagent extension list output on this agent version.' + } } -Log "TLS Registry: $($tlsRegLog -join ' ; ')" Info -NoCount - -# ========================================================================= -# PHASE 4: PKI/OCSP/CRL BYPASS VALIDATION -# ========================================================================= +Write-Status 'Extensions' $(if ($script:InstalledExts.Count -gt 0) { $script:InstalledExts -join ', ' } elseif ($CheckIncludeAll) { 'all (forced)' } else { '(none detected)' }) $(if ($script:InstalledExts.Count -gt 0) { 'White' } else { 'DarkGray' }) $pkiEndpoints = @( - 'oneocsp.microsoft.com' # OCSP primary - 'crl.microsoft.com' # CRL Microsoft root - 'crl2.microsoft.com' # CRL Microsoft intermediate - 'crl3.digicert.com' # CRL DigiCert - 'crl4.digicert.com' # CRL DigiCert alt - 'ocsp.digicert.com' # OCSP DigiCert - 'ctldl.windowsupdate.com' # Certificate Trust List - 'www.microsoft.com' # PKI AIA chain + /pkiops/certs (ESU HTTP:80+HTTPS:443) - 'caissuers.microsoft.com' # CA Issuers (AIA) - 'login.live.com' # Live ID cert validation + 'oneocsp.microsoft.com', + 'ocsp.msocsp.com', + 'crl.microsoft.com', + 'crl2.microsoft.com', + 'crl3.microsoft.com', + 'crl4.microsoft.com', + 'crl3.digicert.com', + 'crl4.digicert.com', + 'ocsp.digicert.com', + 'ctldl.windowsupdate.com', + 'www.microsoft.com', + 'caissuers.microsoft.com', + 'login.live.com' ) -$pkiWildcardCovers = @{ - '.microsoft.com' = @('oneocsp.microsoft.com', 'crl.microsoft.com', 'crl2.microsoft.com', - 'www.microsoft.com', 'caissuers.microsoft.com') - '.digicert.com' = @('crl3.digicert.com', 'crl4.digicert.com', 'ocsp.digicert.com') - '.live.com' = @('login.live.com') - '.ocsp.microsoft.com' = @('oneocsp.microsoft.com') - '.ocsp.digicert.com' = @('ocsp.digicert.com') -} - -function Test-PkiBypassCoverage { - if (-not $script:WinHttpProxy) { return @() } - - $byList = @() +function Get-PkiBypassEntries { + $bypassEntries = @() if ($script:WinHttpBypass) { - # WinHTTP uses *.domain.com format; normalize to .domain.com for matching - $byList += $script:WinHttpBypass -split ';' | - ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } | - ForEach-Object { if ($_ -match '^\*\.([a-z])') { $_.Substring(1) } else { $_ } } + $bypassEntries += $script:WinHttpBypass -split ';' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } } if ($envNoProxy) { - $byList += $envNoProxy -split ',' | - ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } + $bypassEntries += $envNoProxy -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ } } - if ($byList.Count -eq 0) { return $pkiEndpoints } + return @($bypassEntries | Select-Object -Unique) +} - $covered = [System.Collections.ArrayList]::new() - foreach ($wc in $pkiWildcardCovers.Keys) { - if ($byList -contains $wc.ToLower()) { - foreach ($ep in $pkiWildcardCovers[$wc]) { - if ($covered -notcontains $ep.ToLower()) { [void]$covered.Add($ep.ToLower()) } - } +function Test-IsPkiCoveredByBypassOnly { + param([string]$Endpoint) + + $endpointHost = $Endpoint.Trim().ToLower() + foreach ($entry in (Get-PkiBypassEntries)) { + if (-not $entry) { continue } + $norm = $entry.Trim().ToLower() + if (-not $norm) { continue } + if ($norm.StartsWith('*.')) { $norm = $norm.Substring(1) } + if ($endpointHost -eq $norm.TrimStart('.') -or ($norm.StartsWith('.') -and $endpointHost.EndsWith($norm))) { + return $true } } + return $false +} + +function Get-PkiBypassGaps { + if (-not $script:WinHttpProxy) { return @() } + + $bypassEntries = Get-PkiBypassEntries + if ($bypassEntries.Count -eq 0) { return $pkiEndpoints } - $uncovered = @() + $missing = @() foreach ($ep in $pkiEndpoints) { - $lo = $ep.ToLower() - if ($byList -contains $lo) { continue } - if ($covered -contains $lo) { continue } - $matched = $false - foreach ($be in $byList) { - if ($be.StartsWith('.') -and $lo.EndsWith($be)) { $matched = $true; break } + $covered = $false + $endpointHost = $ep.Trim().ToLower() + foreach ($entry in $bypassEntries) { + if (-not $entry) { continue } + $norm = $entry.Trim().ToLower() + if (-not $norm) { continue } + if ($norm.StartsWith('*.')) { $norm = $norm.Substring(1) } + if ($endpointHost -eq $norm.TrimStart('.') -or ($norm.StartsWith('.') -and $endpointHost.EndsWith($norm))) { + $covered = $true + break + } } - if (-not $matched) { $uncovered += $ep } + if (-not $covered) { $missing += $ep } } - return $uncovered + return $missing } if (-not $SkipPKI -and $script:WinHttpProxy) { - Write-Section 'PKI/OCSP/CRL Proxy Bypass' - $uncPki = Test-PkiBypassCoverage - if ($uncPki.Count -eq 0) { - Write-Host ' All PKI endpoints covered by bypass list' -ForegroundColor Green + Write-Section 'PKI / OCSP / CRL Bypass' + $missingPki = Get-PkiBypassGaps + if ($missingPki.Count -eq 0) { + Write-Status 'PKI bypass' 'Coverage looks reasonable via local bypass settings' Green } else { - Write-Host ' PKI endpoints MISSING from proxy bypass (TLS will fail):' -ForegroundColor Red - $bFmt = " {0,-9} | {1}" - Write-Host ($bFmt -f 'Status', 'Endpoint') -ForegroundColor Gray - Write-Host (" {0,-9}-+-{1}" -f ('-' * 9), ('-' * 40)) -ForegroundColor DarkGray - foreach ($ep in $uncPki) { - Write-Host ($bFmt -f 'MISSING', $ep) -ForegroundColor Red - Log "PKI bypass MISSING: $ep" Fail - } - Add-Issue -Sev 'CRITICAL' -Cat 'PKI Bypass' ` - -Msg "$($uncPki.Count) PKI endpoint(s) not in proxy bypass" ` - -Fix "Add to GPO NO_PROXY: $($uncPki -join ',')" + Write-Status 'PKI bypass' ("Local bypass coverage is incomplete: $($missingPki -join ', ')") Yellow + Add-Issue -Severity 'INFO' -Category 'PKI Coverage' -Message 'Some PKI endpoints are not covered by local bypass configuration (WinHTTP bypass / NO_PROXY). Treat this as inventory only; it is not causal evidence of failure for this run.' -Fix 'Correlate with observed HTTP/TLS probe errors before changing proxy, firewall, route, or PKI policy.' } } -# ========================================================================= -# PHASE 5: ENDPOINT DEFINITIONS -# ========================================================================= - -# Reset stats for test phase -$script:Stats.OK = 0 -$script:Stats.Fail = 0 -$script:Stats.Warn = 0 - -# Endpoints eligible for Private Link resolution -$canBePrivate = [System.Collections.ArrayList]@( - 'gbl.his.arc.azure.com' +Write-Section 'Endpoint Discovery (azcmagent check)' +$coreEndpoints = [System.Collections.ArrayList]@( + 'login.windows.net', + 'login.microsoftonline.com', + "$Region.login.microsoft.com", + 'pas.windows.net', + 'gbl.his.arc.azure.com', 'agentserviceapi.guestconfiguration.azure.com' - 'dc.services.visualstudio.com' - 'global.handler.control.monitor.azure.com' ) - -# --- Core endpoints (always tested) --- -# Ref: https://learn.microsoft.com/azure/azure-arc/network-requirements-consolidated -$coreEps = [System.Collections.ArrayList]@( - 'login.windows.net' # AAD authentication - 'login.microsoftonline.com' # AAD authentication - "$Region.login.microsoft.com" # AAD regional - 'pas.windows.net' # AAD token (access packages) - 'management.azure.com' # ARM (Azure Resource Manager) - 'gbl.his.arc.azure.com' # Arc Hybrid Identity Service (global) - 'agentserviceapi.guestconfiguration.azure.com' # Guest Configuration (global) - 'packages.microsoft.com' # Agent/extension packages (Linux apt/yum) - 'download.microsoft.com' # Agent installer + extension downloads - 'mcr.microsoft.com' # Extension container images +$controlPlaneEndpoints = [System.Collections.ArrayList]@( + 'management.azure.com' +) +$lifecycleEndpoints = [System.Collections.ArrayList]@( + 'packages.microsoft.com', + 'download.microsoft.com' +) +$privateEligible = [System.Collections.ArrayList]@( + 'gbl.his.arc.azure.com', + 'agentserviceapi.guestconfiguration.azure.com', + 'global.handler.control.monitor.azure.com' ) -# dc.services.visualstudio.com — not used in agent 1.24+ (replaced by ARM telemetry) -$agVerParts = if ($script:AgentVersion) { $script:AgentVersion -split '\.' } else { @() } -$agMajor = if ($agVerParts.Count -ge 1) { try { [int]$agVerParts[0] } catch { 0 } } else { 0 } -$agMinor = if ($agVerParts.Count -ge 2) { try { [int]$agVerParts[1] } catch { 0 } } else { 0 } -if ($script:PreOnboarding -or ($agMajor -lt 1) -or ($agMajor -eq 1 -and $agMinor -lt 24)) { - [void]$coreEps.Add('dc.services.visualstudio.com') -} - -# GNS global (Public/Gateway modes) -if ($Mode -in 'Public', 'Gateway') { - [void]$coreEps.Add('guestnotificationservice.azure.com') +if ($Mode -in @('Public', 'Gateway')) { + [void]$coreEndpoints.Add('guestnotificationservice.azure.com') } -# Gateway URL -if ($script:GatewayUrl) { - try { - $gwFqdn = ([System.Uri]$script:GatewayUrl).Host - if ($gwFqdn) { [void]$coreEps.Add($gwFqdn) } - } - catch { - $gwFqdn = $script:GatewayUrl -replace 'https?://', '' -replace '/.*', '' - if ($gwFqdn) { [void]$coreEps.Add($gwFqdn) } +if ($script:AgentJson) { + $gw = $null + if ($script:AgentJson.PSObject.Properties['gatewayUrl']) { $gw = $script:AgentJson.gatewayUrl } + elseif ($script:AgentJson.PSObject.Properties['gatewayurl']) { $gw = $script:AgentJson.gatewayurl } + if ($gw) { + $script:GatewayUrl = $gw + try { + $gwHost = ([System.Uri]$gw).Host + if ($gwHost -and $coreEndpoints -notcontains $gwHost) { [void]$coreEndpoints.Add($gwHost) } + } + catch { } } } -# --- Extension endpoints (auto-detected) --- -$extEps = @{} - -# SQL Server enabled by Azure Arc -# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint -# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/prerequisites +$extEndpoints = @{} if ($script:InstalledExts -contains 'SQL' -or $CheckIncludeAll) { - $extEps['SQL'] = @( - "dataprocessingservice.$Region.arcdataservices.com" # Data processing (telemetry upload) - "telemetry.$Region.arcdataservices.com" # Telemetry collection - "san-af-$Region-prod.azurewebsites.net" # SQL Assessment (legacy, may be deprecated) - 'graph.microsoft.com' # AAD Graph for SQL auth + $extEndpoints['SQL'] = @( + "dataprocessingservice.$Region.arcdataservices.com", + "telemetry.$Region.arcdataservices.com", + "san-af-$Region-prod.azurewebsites.net", + 'graph.microsoft.com', + 'dc.services.visualstudio.com' ) } - -# Defender for SQL (separate from MDE) -# Ref: https://learn.microsoft.com/azure/defender-for-cloud/defender-for-sql-usage if ($script:InstalledExts -contains 'DSQL' -or $CheckIncludeAll) { - if (-not $extEps.ContainsKey('SQL')) { $extEps['SQL'] = @() } - $extEps['SQL'] += @("defender-for-databases.$Region.arcdataservices.com") + if (-not $extEndpoints.ContainsKey('SQL')) { $extEndpoints['SQL'] = @() } + $extEndpoints['SQL'] += "defender-for-databases.$Region.arcdataservices.com" } - -# AMA (Azure Monitor Agent) + Dependency Agent -# Ref: https://learn.microsoft.com/azure/azure-monitor/agents/azure-monitor-agent-network-configuration if ($script:InstalledExts -contains 'AMA' -or $script:InstalledExts -contains 'DA' -or $CheckIncludeAll) { - $extEps['AMA'] = @( - 'global.handler.control.monitor.azure.com' # Agent control channel (global) - "$Region.handler.control.monitor.azure.com" # Agent control channel (regional) - "$Region.monitoring.azure.com" # Metrics ingestion (DCR) - 'global.prod.microsoftmetrics.com' # Metrics publishing + $extEndpoints['AMA'] = @( + 'global.handler.control.monitor.azure.com', + 'global.prod.microsoftmetrics.com', + "$Region.handler.control.monitor.azure.com", + "$Region.monitoring.azure.com" ) - # NOTE: Log ingestion uses ..ingest.monitor.azure.com - # which is customer-specific (Data Collection Endpoint). Not testable without DCE context. } - -# MDE (Microsoft Defender for Endpoint) -# Ref: https://learn.microsoft.com/defender-endpoint/configure-proxy-internet -# Ref: https://learn.microsoft.com/defender-endpoint/configure-environment#streamlined-connectivity -# NOTE: MDE endpoints vary by tenant geo (US/EU/UK). Below are US defaults. -# If tenant is in EU/UK, these will differ. Agent geo is detected from onboarding blob. if ($script:InstalledExts -contains 'MDE' -or $CheckIncludeAll) { - $extEps['MDE'] = @( - 'unitedstates.x.cp.wd.microsoft.com' # Cyber data (US geo) - 'us-v20.events.data.microsoft.com' # EDR telemetry (US geo) - 'winatp-gw-cus3.microsoft.com' # Gateway (Central US) - 'go.microsoft.com' # MDE update/CnC channel - '*.endpoint.security.microsoft.com' # Unified MDE endpoint (streamlined) + $extEndpoints['MDE'] = @( + 'unitedstates.x.cp.wd.microsoft.com', + 'us-v20.events.data.microsoft.com', + 'winatp-gw-cus3.microsoft.com' ) } - -# WAC (Windows Admin Center) if ($script:InstalledExts -contains 'WAC' -or $CheckIncludeAll) { - $extEps['WAC'] = @("$Region.service.waconazure.com") + $extEndpoints['WAC'] = @("$Region.service.waconazure.com") } - -# Key Vault extension if ($script:InstalledExts -contains 'KV' -or $CheckIncludeAll) { - $extEps['KV'] = @('*.vault.azure.net') + $extEndpoints['KV'] = @('*.vault.azure.net') } - -# Hybrid Runbook Worker -# Ref: https://learn.microsoft.com/azure/automation/automation-hybrid-runbook-worker#network-planning if ($script:InstalledExts -contains 'HRW' -or $CheckIncludeAll) { - $extEps['HRW'] = @( - '*.azure-automation.net' # Automation account - '*.agentsvc.azure-automation.net' # Agent service - '*.jrds.azure-automation.net' # Job Runtime Data Service - ) + $extEndpoints['HRW'] = @('*.azure-automation.net', '*.agentsvc.azure-automation.net') } - -# Update Manager if ($script:InstalledExts -contains 'UM' -or $CheckIncludeAll) { - $extEps['UM'] = @("$Region.monitoring.azure.com") + $extEndpoints['UM'] = @("$Region.monitoring.azure.com") } - -# Guest Attestation if ($script:InstalledExts -contains 'GA' -or $CheckIncludeAll) { - $extEps['GA'] = @('*.attest.azure.net') -} - -# --- Extension download infrastructure (wildcard, always needed if any extension) --- -# Ref: https://learn.microsoft.com/azure/azure-arc/servers/network-requirements -# These are wildcards — cannot be TCP-tested but must be in firewall allow list -$extDownloadWildcards = @( - '*.blob.core.windows.net' # Extension package download (Azure Storage) - '*.dl.delivery.mp.microsoft.com' # Extension package download (CDN alt) - '*.data.mcr.microsoft.com' # Container image layers (MCR) - '*.servicebus.windows.net' # GNS notification channel (Public mode) - '*.ods.opinsights.azure.com' # Log Analytics data ingestion (AMA/MMA) - '*.oms.opinsights.azure.com' # Log Analytics management (AMA/MMA) -) - -# -SkipExtensions overrides -CheckIncludeAll (user explicitly asked to skip) -if ($SkipExtensions -and $extEps.Count -gt 0) { - $extEps = @{} - Log 'Extension endpoints skipped (-SkipExtensions)' Info -NoCount + $extEndpoints['GA'] = @('*.attest.azure.net') } +if ($SkipExtensions) { $extEndpoints = @{} } -# ========================================================================= -# PHASE 6: ENDPOINT DISCOVERY (azcmagent check) -# ========================================================================= -# Regional Arc endpoints use unpredictable abbreviations (e.g. eus2, brs, ncus). -# Instead of guessing, we parse 'azcmagent check' output to discover the actual -# endpoints the agent uses. - -Write-Section 'Endpoint Discovery (azcmagent check)' - -$script:AzcmagentCheckExit = $null -$discoveredEps = @() - +$discoveredEndpoints = @() if ($azcm) { - $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud') + $checkArgs = @('check', '--location', $Region, '--cloud', 'AzureCloud', '--verbose') if ($CheckIncludeAll) { $checkArgs += @('--extensions', 'all', '--include-all') } elseif ($script:InstalledExts -contains 'SQL') { $checkArgs += @('--extensions', 'sql') } - if ($Mode -eq 'Private') { $checkArgs += '--enable-pls-check' } + if ($Mode -eq 'Private') { + $checkArgs += '--enable-pls-check' + } Write-Host " azcmagent $($checkArgs -join ' ')" -ForegroundColor Gray try { - $out = & $azcm @checkArgs 2>&1 + $checkOut = & $azcm @checkArgs 2>&1 $script:AzcmagentCheckExit = $LASTEXITCODE - Save-Log - Add-Content -Path $LogFilePath -Value $out + Add-Content -Path $LogFilePath -Value '' + Add-Content -Path $LogFilePath -Value '=================== AZCMAGENT CHECK ===================' + Add-Content -Path $LogFilePath -Value $checkOut - # Parse pipe-delimited output to extract endpoint FQDNs - foreach ($line in $out) { + foreach ($line in $checkOut) { $s = "$line".Trim() if ($s -match '\|\s*https?://([^\s|/]+)') { $fqdn = $Matches[1] - if ($fqdn -and $fqdn -notmatch '^(Use Case|Endpoint)') { - $discoveredEps += $fqdn + if ($fqdn -and $fqdn -notmatch '^(Use Case|Endpoint)$') { + $discoveredEndpoints += $fqdn + } + } + + if ($s -match 'Endpoint properties\s+(.+)$') { + $payload = $Matches[1] + $hostName = $null + $useCase = $null + $reachable = $null + $proxyStatus = $null + $private = $null + $tls = $null + + if ($payload -match 'hostname=([^\s]+)') { $hostName = $Matches[1] } + if ($payload -match 'useCase=([^\s]+)') { $useCase = $Matches[1] } + if ($payload -match 'reachable=(true|false)') { $reachable = ($Matches[1] -eq 'true') } + if ($payload -match 'proxyStatus=("[^"]+"|[^\s]+)') { $proxyStatus = $Matches[1].Trim('"') } + if ($payload -match 'private=(true|false|unknown)') { $private = $Matches[1] } + if ($payload -match 'tls=("[^"]+"|[^\s]+)') { $tls = $Matches[1].Trim('"') } + + if ($hostName) { + $script:AzcmagentEndpointMeta[$hostName] = [pscustomobject]@{ + HostName = $hostName + UseCase = $useCase + Group = Convert-AzcmagentUseCaseToGroup -UseCase $useCase + Reachable = $reachable + ProxyStatus = $proxyStatus + Private = $private + Tls = $tls + } + } + } + + if ($s -match 'Check result\s+check_name="[^"]+"\s+endpoint="https?://([^"]+)".*status=(failureed|failed|failure)') { + $failedEndpoint = $Matches[1] + if ($failedEndpoint -and $script:AzcmagentFailedEndpoints -notcontains $failedEndpoint) { + [void]$script:AzcmagentFailedEndpoints.Add($failedEndpoint) } } - } - $discoveredEps = $discoveredEps | Select-Object -Unique - if ($script:AzcmagentCheckExit -eq 0) { - Write-Host " PASSED - discovered $($discoveredEps.Count) endpoints" -ForegroundColor Green + if ($s -match 'checks_failed=(\d+)') { + $script:AzcmagentChecksFailed = [int]$Matches[1] + } + if ($s -match 'critical_failures=(\d+)') { + $script:AzcmagentCriticalFailures = [int]$Matches[1] + } + if ($s -match 'All endpoints needed to connect to Azure are available\.') { + $script:AzcmagentCoreHealthy = $true + } } - else { - Write-Host " FAILED (exit $($script:AzcmagentCheckExit)) - discovered $($discoveredEps.Count) endpoints" -ForegroundColor Red - Add-Issue -Sev 'HIGH' -Cat 'Agent Check' ` - -Msg "azcmagent check failed (exit $($script:AzcmagentCheckExit))" ` - -Fix 'Review azcmagent check output in log file' + $discoveredEndpoints = $discoveredEndpoints | Select-Object -Unique + $summary = "exit $($script:AzcmagentCheckExit); discovered $($discoveredEndpoints.Count) endpoints" + if ($null -ne $script:AzcmagentChecksFailed) { + $summary += "; checks_failed=$($script:AzcmagentChecksFailed)" + } + if ($null -ne $script:AzcmagentCriticalFailures) { + $summary += "; critical_failures=$($script:AzcmagentCriticalFailures)" + } + Write-Status 'azcmagent check' $summary $(if ($script:AzcmagentCheckExit -eq 0) { 'Green' } else { 'Yellow' }) + if ($script:AzcmagentCheckExit -ne 0 -and (($null -eq $script:AzcmagentCriticalFailures) -or $script:AzcmagentCriticalFailures -gt 0)) { + $failedCoreDetail = '' + if ($script:AzcmagentFailedEndpoints.Count -gt 0) { + $failedCoreDetail = ' Failed required endpoint(s): ' + (($script:AzcmagentFailedEndpoints | Select-Object -Unique) -join ', ') + '.' + } + Add-Issue -Severity 'HIGH' -Category 'Core Connectivity' -Message ("azcmagent reported required Arc connectivity failures." + $failedCoreDetail) -Fix 'Review the failed required endpoint(s), gateway/proxy path, and azcmagent check section in the log file.' } } catch { - Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red + Write-Status 'azcmagent check' "Failed: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'HIGH' -Category 'AgentCheck' -Message 'azcmagent check could not be executed.' -Fix 'Validate azcmagent installation and command availability.' } } else { - Write-Host ' azcmagent not found - using DNS-based regional endpoint discovery' -ForegroundColor Yellow - - # --- Regional endpoint fallback (no agent) --- - # his.arc.azure.com uses unpredictable abbreviations per region. - # We try a known map + DNS probing to discover the correct FQDN. - $regionAbbrevMap = @{ - 'eastus'='eus'; 'eastus2'='eus2'; 'westus'='wus'; 'westus2'='wus2'; 'westus3'='wus3' - 'centralus'='cus'; 'northcentralus'='ncus'; 'southcentralus'='scus'; 'westcentralus'='wcus' - 'canadacentral'='cac'; 'canadaeast'='cae' - 'brazilsouth'='brs'; 'brazilsoutheast'='brse' - 'northeurope'='neu'; 'westeurope'='weu' - 'uksouth'='uks'; 'ukwest'='ukw' - 'francecentral'='frc'; 'francesouth'='frs' - 'germanywestcentral'='gwc'; 'switzerlandnorth'='szn'; 'switzerlandwest'='szw' - 'norwayeast'='noe'; 'norwaywest'='now'; 'swedencentral'='sec' - 'australiaeast'='aue'; 'australiasoutheast'='ause' - 'eastasia'='ea'; 'southeastasia'='sea' - 'japaneast'='jpe'; 'japanwest'='jpw' - 'koreacentral'='krc'; 'koreasouth'='krs' - 'centralindia'='inc'; 'southindia'='ins'; 'westindia'='inw' - 'southafricanorth'='san'; 'southafricawest'='saw' - 'uaenorth'='uan'; 'uaecentral'='uac' - 'qatarcentral'='qac'; 'polandcentral'='plc'; 'italynorth'='itn' - } - - # Try HIS endpoint: abbreviation first, then full name - $hisCandidates = @() - $rLower = $Region.ToLower() - if ($regionAbbrevMap.ContainsKey($rLower)) { - $hisCandidates += "$($regionAbbrevMap[$rLower]).his.arc.azure.com" - } - $hisCandidates += "$Region.his.arc.azure.com" - - foreach ($hc in $hisCandidates) { - try { - $null = Resolve-DnsName -Name $hc -ErrorAction Stop - $discoveredEps += $hc - Write-Host " Discovered: $hc" -ForegroundColor Green - break - } - catch { } + Write-Status 'azcmagent check' 'Skipped - azcmagent not installed' Yellow +} + +foreach ($ep in $discoveredEndpoints) { + if ($coreEndpoints -notcontains $ep) { [void]$coreEndpoints.Add($ep) } + if ($ep -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') { + if ($privateEligible -notcontains $ep) { [void]$privateEligible.Add($ep) } } +} - # GuestConfiguration always uses full region name with -gas suffix - $gcCandidate = "$Region-gas.guestconfiguration.azure.com" - try { - $null = Resolve-DnsName -Name $gcCandidate -ErrorAction Stop - $discoveredEps += $gcCandidate - Write-Host " Discovered: $gcCandidate" -ForegroundColor Green +$script:AzcmagentPrivatePathHealthy = (@( + $script:AzcmagentEndpointMeta.Values | Where-Object { + $_.Reachable -eq $true -and ($privateEligible -contains $_.HostName) -and ( + $_.Private -eq 'true' -or + ($_.ProxyStatus -eq 'bypassed' -and $_.HostName -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') + ) } - catch { } +).Count -gt 0) - if ($discoveredEps.Count -gt 0) { - Write-Host " Discovered $($discoveredEps.Count) regional endpoint(s) via DNS" -ForegroundColor Green +if ($script:EffectiveProxy -and $script:EffectiveProxyParseError -eq $null -and $script:EffectiveProxyReachable -eq $false) { + $pxUri = [System.Uri]$script:EffectiveProxy + $proxyCoreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) + $proxyPrivatePathHealthyObserved = ( + $script:AzcmagentPrivatePathHealthy -or + @( + $script:AzcmagentEndpointMeta.Values | Where-Object { + $_.Reachable -eq $true -and ($privateEligible -contains $_.HostName) -and ( + $_.Private -eq 'true' -or + ($_.ProxyStatus -eq 'bypassed' -and $_.HostName -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') + ) + } + ).Count -gt 0 + ) + + if ($Mode -eq 'Private' -and $proxyCoreHealthyNoCritical -and $proxyPrivatePathHealthyObserved) { + Add-Issue -Severity 'WARN' -Category 'Proxy' -Message "Configured proxy $($pxUri.Host):$($pxUri.Port) is not reachable. In Private/split-network mode this is treated as a non-blocking proxy-path warning because Arc private-capable endpoints remained healthy." -Fix 'Validate proxy host, port, routing, and firewall if ARM control-plane or other proxy-routed endpoints are in scope.' } else { - Write-Host ' No regional endpoints discovered (verify -Region parameter)' -ForegroundColor Yellow - Add-Issue -Sev 'WARN' -Cat 'Discovery' ` - -Msg "Could not discover regional endpoints for region '$Region'" ` - -Fix 'Verify -Region parameter or install azcmagent first' + Add-Issue -Severity 'CRITICAL' -Category 'Proxy' -Message "Configured proxy $($pxUri.Host):$($pxUri.Port) is not reachable." -Fix 'Validate proxy host, port, routing, and firewall.' } } -# Merge discovered endpoints into core list (avoid duplicates) -foreach ($dep in $discoveredEps) { - if ($coreEps -notcontains $dep) { [void]$coreEps.Add($dep) } - # Mark PLS-eligible patterns - if ($dep -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') { - if ($canBePrivate -notcontains $dep) { [void]$canBePrivate.Add($dep) } +if ($script:DeferredTlsIssue) { + $tlsCoreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) + if ($script:DeferredTlsIssue.UsesProxy -and $tlsCoreHealthyNoCritical) { + Write-Status 'TLS interpretation' 'Proxy-path TLS probe failed, but azcmagent reported core connectivity healthy' DarkGray + } + else { + Add-Issue -Severity $script:DeferredTlsIssue.Severity -Category 'TLS' -Message $script:DeferredTlsIssue.Message -Fix $script:DeferredTlsIssue.Fix } } -# --- Build final endpoint group map --- $endpointGroupMap = @{} -foreach ($ep in $coreEps) { $endpointGroupMap[$ep] = 'Core' } -foreach ($grp in $extEps.Keys) { - foreach ($ep in $extEps[$grp]) { - if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = $grp } +foreach ($ep in $coreEndpoints) { + $endpointGroupMap[$ep] = if ($script:AzcmagentEndpointMeta.ContainsKey($ep)) { $script:AzcmagentEndpointMeta[$ep].Group } else { 'Core' } +} +foreach ($ep in $controlPlaneEndpoints) { + $endpointGroupMap[$ep] = 'ControlPlane' +} +foreach ($ep in $lifecycleEndpoints) { + if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'Lifecycle' } +} +foreach ($grp in $extEndpoints.Keys) { + foreach ($ep in $extEndpoints[$grp]) { + $endpointGroupMap[$ep] = $grp } } if (-not $SkipPKI) { @@ -1309,559 +1533,546 @@ if (-not $SkipPKI) { if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'PKI' } } } -# Extension download infrastructure wildcards -foreach ($ep in $extDownloadWildcards) { - if (-not $endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] = 'DL' } -} -# --- Dynamic GNS allowlist (Public mode only) --- -$dynamicEps = @() +$dynamicEndpoints = @() if ($Mode -eq 'Public') { try { - $uri = "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$Region" - $resp = Invoke-HttpSafe -Uri $uri - $dynamicEps = @($resp.Content | ConvertFrom-Json) | Where-Object { $_ } - if ($dynamicEps.Count -gt 0) { - # Filter to primary namespaces only - $pids = [System.Collections.ArrayList]::new() - foreach ($d in $dynamicEps) { - if ($d -match '^azgn-.+\dp-.+?-(\w+)\.servicebus') { [void]$pids.Add($Matches[1]) } - } - if ($pids.Count -gt 0) { - $filtered = [System.Collections.ArrayList]::new() - foreach ($d in $dynamicEps) { - if ($d -match '^azgn-') { [void]$filtered.Add($d) } - else { - foreach ($cid in $pids) { - if ($d -like "*$cid*") { [void]$filtered.Add($d); break } - } - } - } - $dynamicEps = @($filtered) - } - foreach ($d in $dynamicEps) { $endpointGroupMap[$d] = 'GNS' } + $gnsResp = Invoke-HttpSafe -Uri "https://guestnotificationservice.azure.com/urls/allowlist?api-version=2020-01-01&location=$Region" -TimeoutSec 10 + $dynamicEndpoints = @($gnsResp.Content | ConvertFrom-Json) | Where-Object { $_ } | Select-Object -Unique + foreach ($ep in $dynamicEndpoints) { + $endpointGroupMap[$ep] = 'GNS' } + Write-Status 'GNS allowlist' ("Loaded $($dynamicEndpoints.Count) endpoints") Green } catch { - Log "GNS dynamic allowlist failed: $($_.Exception.Message)" Warn + Write-Status 'GNS allowlist' "Failed: $($_.Exception.Message)" Yellow + Add-Issue -Severity 'WARN' -Category 'GNS' -Message 'Could not retrieve dynamic GNS allowlist.' -Fix 'Validate guestnotificationservice.azure.com reachability if GNS is needed.' } } -# --- Build combined testable list --- $allTestable = @() -foreach ($ep in $coreEps) { $allTestable += $ep } -foreach ($grp in $extEps.Keys) { - foreach ($ep in $extEps[$grp]) { $allTestable += $ep } -} -$allTestable += $dynamicEps +$allTestable += $coreEndpoints +$allTestable += $controlPlaneEndpoints +$allTestable += $lifecycleEndpoints +foreach ($grp in $extEndpoints.Keys) { $allTestable += $extEndpoints[$grp] } +$allTestable += $dynamicEndpoints if (-not $SkipPKI) { $allTestable += $pkiEndpoints } +$wildcardEndpoints = @($allTestable | Where-Object { $_ -match '^\*\.' } | Select-Object -Unique) +$allTestable = @($allTestable | Where-Object { $_ -and $_ -notmatch '^\*\.' } | Select-Object -Unique) -# Add extension download wildcards (always needed) -$allTestable += $extDownloadWildcards - -# Separate wildcards (informational, not testable) from concrete FQDNs -$wildcardEps = @($allTestable | Where-Object { $_ -match '^\*\.' } | Select-Object -Unique) -$allTestable = @($allTestable | Where-Object { $_ -notmatch '^\*\.' } | Where-Object { $_ } | Select-Object -Unique) - -# --- HTTP probe endpoints --- -$httpProbeEps = @('login.windows.net', 'login.microsoftonline.com', 'management.azure.com') -if ($extEps.ContainsKey('SQL')) { - $httpProbeEps += "dataprocessingservice.$Region.arcdataservices.com" - $httpProbeEps += "telemetry.$Region.arcdataservices.com" -} -# Probe Gateway URL if configured -if ($script:GatewayUrl) { - $httpProbeEps += $script:GatewayUrl -} - -# --- Agent proxy.bypass => skip HTTP for bypassed endpoints --- -$httpBypassedEps = [System.Collections.ArrayList]::new() -if ($azcm -and $script:EffectiveProxy) { - $bypassCats = @() - try { - $bRaw = (& $azcm config get proxy.bypass 2>$null | Out-String).Trim() - if ($bRaw) { - $bypassCats = $bRaw.Trim('[', ']') -split ',' | - ForEach-Object { $_.Trim() } | Where-Object { $_ } - } - } - catch { } - - $catMap = @{ - 'AAD' = @('login.windows.net', 'login.microsoftonline.com', 'pas.windows.net') - 'ARM' = @('management.azure.com') - 'Arc' = @('gbl.his.arc.azure.com', 'agentserviceapi.guestconfiguration.azure.com') - 'ArcData' = @("dataprocessingservice.$Region.arcdataservices.com", - "telemetry.$Region.arcdataservices.com") - 'AMA' = @('global.handler.control.monitor.azure.com', - "$Region.handler.control.monitor.azure.com") - } - foreach ($cat in $bypassCats) { - if ($catMap.ContainsKey($cat)) { - foreach ($ep in $catMap[$cat]) { - if ($httpBypassedEps -notcontains $ep) { [void]$httpBypassedEps.Add($ep) } - } +$httpProbeEndpoints = @( + 'login.microsoftonline.com' +) +if ($script:PreOnboarding -or $script:ProxyMode -eq 'ExplicitProxy' -or $Mode -in @('Private', 'Gateway')) { + $httpProbeEndpoints += 'management.azure.com' +} +if ($extEndpoints.ContainsKey('SQL')) { + $httpProbeEndpoints += "dataprocessingservice.$Region.arcdataservices.com" + $httpProbeEndpoints += "telemetry.$Region.arcdataservices.com" +} +$httpProbeEndpoints += 'oneocsp.microsoft.com' +$httpProbeEndpoints = $httpProbeEndpoints | Select-Object -Unique + +$httpBypassedEndpoints = Get-AgentBypassedEndpoints -AgentBypass $agentBypass -Region $Region + +Write-Banner ("TESTING $($allTestable.Count) ENDPOINTS") +$index = 0 +foreach ($endpoint in $allTestable) { + $index++ + $group = if ($endpointGroupMap.ContainsKey($endpoint)) { $endpointGroupMap[$endpoint] } else { 'Core' } + Add-Result -Endpoint $endpoint -Group $group + + $percent = [math]::Round(($index / [Math]::Max($allTestable.Count, 1)) * 100) + $short = if ($endpoint.Length -gt 58) { $endpoint.Substring(0, 55) + '...' } else { $endpoint } + Write-Host ("`r [{0,3}%] {1,-60}" -f $percent, $short) -NoNewline -ForegroundColor Gray + + $resolution = Resolve-Endpoint -Endpoint $endpoint + if ($resolution.Error) { + $coreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) + $pkiWarnOnly = ($group -eq 'PKI' -and $coreHealthyNoCritical) + $dnsSeverity = if ((Test-IsOptionalEndpoint -Endpoint $endpoint -Group $group) -or $pkiWarnOnly) { 'WARN' } else { 'FAIL' } + $dnsFailureNote = if ($pkiWarnOnly) { 'Name resolution failed; PKI-only warning while Arc core remains healthy.' } else { 'Name resolution failed' } + Add-Result -Endpoint $endpoint -DNS $dnsSeverity -Notes $dnsFailureNote + switch ($dnsSeverity) { + 'WARN' { $script:Stats.DNSWarn++ } + 'FAIL' { $script:Stats.DNSFail++ } } - } -} - -# ========================================================================= -# PHASE 7: CONNECTIVITY TESTS (DNS + TCP + HTTP) -# ========================================================================= - -Write-Banner "PHASE 2: CONNECTIVITY TESTS ($($allTestable.Count) endpoints)" - -# Gateway mode: verify Arc Proxy is listening on localhost:40343 -if ($Mode -eq 'Gateway' -and -not $script:PreOnboarding) { - Write-Section 'Arc Gateway Proxy (localhost:40343)' - $gwProxyOk = Test-TcpPort -H '127.0.0.1' -P 40343 -T 3000 - if ($gwProxyOk) { - Write-Status 'Arc Proxy' 'localhost:40343 reachable' Green - Log 'Arc Gateway Proxy localhost:40343 reachable' OK - } - else { - Write-Status 'Arc Proxy' 'localhost:40343 NOT reachable' Red - Log 'Arc Gateway Proxy localhost:40343 NOT reachable' Fail - Add-Issue -Sev 'CRITICAL' -Cat 'Gateway' ` - -Msg 'Arc Gateway local proxy (localhost:40343) not responding. Tunneled endpoints will fail.' ` - -Fix 'Verify Arc Gateway is properly configured: azcmagent config get proxy.url' - } - Write-Host '' -} - -$pi = 0 -foreach ($ep in $allTestable) { - $ep = $ep.Trim() - if (-not $ep) { continue } - $pi++ - - $grp = if ($endpointGroupMap.ContainsKey($ep)) { $endpointGroupMap[$ep] } else { 'Core' } - $epPath = if (Test-IsGatewayTunneled -Endpoint $ep) { 'Tunnel' } else { 'Direct' } - Add-Result -Endpoint $ep -Group $grp -Path $epPath - - $pct = [math]::Round(($pi / $allTestable.Count) * 100) - $epShort = if ($ep.Length -gt 56) { $ep.Substring(0, 53) + '...' } else { $ep } - Write-Host ("`r [{0,3}%] {1,-58}" -f $pct, $epShort) -NoNewline -ForegroundColor Gray - - # --- DNS --- - $dns = $null - $dnsErr = $null - foreach ($a in 1..2) { - try { - $dns = Resolve-DnsName -Name $ep -ErrorAction Stop - $dnsErr = $null - break + if ($dnsSeverity -eq 'FAIL') { + Add-Issue -Severity 'HIGH' -Category 'DNS' -Message "Cannot resolve $endpoint." -Fix 'Validate DNS path, split-DNS, and firewall/DNS forwarding.' } - catch { - $dnsErr = $_ - if ($a -lt 2) { Start-Sleep -Milliseconds 300 } + elseif ($pkiWarnOnly) { + Add-Issue -Severity 'WARN' -Category 'PKI' -Message "PKI endpoint $endpoint could not be resolved." -Fix 'Review PKI/CRL/OCSP reachability only if certificate validation is the target symptom; do not treat this alone as Arc runtime failure when azcmagent reports critical_failures=0.' } - } - - if ($dnsErr) { - $lv = if ($grp -eq 'GNS') { 'Warn' } else { 'Fail' } - Log "DNS $lv $ep - $($dnsErr.Exception.Message)" $lv - $rr = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($rr) { $rr.DNS = $lv.ToUpper() } - if ($lv -eq 'Fail') { - Add-Issue -Sev 'HIGH' -Cat 'DNS' -Msg "Cannot resolve $ep" -Fix 'Check DNS/firewall' + else { + Add-Issue -Severity 'WARN' -Category 'DNS' -Message "Optional endpoint $endpoint could not be resolved." -Fix 'Review DNS only if this optional endpoint is expected for the installed extension set.' } continue } - $rec = $dns | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1 - if (-not $rec) { $rec = $dns | Where-Object IPAddress | Select-Object -First 1 } - $ip = $rec.IPAddress - $kind = if (Test-IsPrivateIp $ip) { 'PRIV' } else { 'PUB' } - - $rr = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($rr) { $rr.IP = $ip; $rr.Type = $kind } + $aRecord = $resolution.Result | Where-Object { $_.Type -eq 'A' -and $_.IPAddress } | Select-Object -First 1 + if (-not $aRecord) { + $aRecord = $resolution.Result | Where-Object { $_.IPAddress } | Select-Object -First 1 + } + $ip = if ($aRecord) { $aRecord.IPAddress } else { '-' } + $type = if (Test-IsPrivateIp -Ip $ip) { 'PRIV' } else { 'PUB' } - $cbp = $canBePrivate -contains $ep - $mm = ($Mode -eq 'Private' -and $kind -eq 'PUB' -and $cbp) -or - ($Mode -eq 'Public' -and $kind -eq 'PRIV') - if ($mm) { - Log "DNS WARN $ep -> $ip [$kind] mode mismatch" Warn - $rr2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($rr2) { $rr2.DNS = 'WARN' } + $dnsState = 'OK' + $dnsNote = '' + if ($Mode -eq 'Private' -and $type -eq 'PUB' -and ($privateEligible -contains $endpoint)) { + $dnsState = 'WARN' + $dnsNote = 'Private mode expected private resolution for this endpoint.' + $script:Stats.DNSWarn++ + Add-Issue -Severity 'WARN' -Category 'DNS' -Message "$endpoint resolved to public IP $ip while mode is Private." -Fix 'Validate Private Link Scope DNS and conditional forwarding.' + } + elseif ($Mode -eq 'Public' -and $type -eq 'PRIV' -and $endpoint -ne 'gbl.his.arc.azure.com') { + $dnsState = 'WARN' + $dnsNote = 'Public mode observed private resolution.' + $script:Stats.DNSWarn++ + Add-Issue -Severity 'WARN' -Category 'DNS' -Message "$endpoint resolved to private IP $ip while mode is Public." -Fix 'Validate split-DNS expectations and ensure this is intentional.' } else { - Log "DNS OK $ep -> $ip" OK - $rr2 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($rr2) { $rr2.DNS = 'OK' } + $script:Stats.DNSOK++ } + Add-Result -Endpoint $endpoint -IP $ip -Type $type -DNS $dnsState -Notes $dnsNote + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $tcpOk = Test-TcpPort -HostName $endpoint -Port 443 -TimeoutMs 5000 + $sw.Stop() + $latency = [math]::Round($sw.Elapsed.TotalMilliseconds, 0) - # --- TCP/443 --- - # In Gateway mode, tunneled endpoints route through localhost:40343 (Arc Proxy) - # so direct TCP to the target IP is NOT expected to work. - $isTunneled = Test-IsGatewayTunneled -Endpoint $ep - if ($isTunneled) { - Log "TCP SKIP ${ep}:443 (tunneled via Gateway)" Info -NoCount - $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($rr3) { $rr3.TCP = 'TUNNEL'; $rr3.Latency = 'n/a' } + $tcpState = 'OK' + $tcpNote = '' + if ($tcpOk) { + $script:Stats.TCPOK++ + $tcpState = 'OK' } else { - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $ok = Test-TcpPort -H $ep -P 443 -T 5000 - $sw.Stop() - $ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 0) - - $rr3 = $script:Results | Where-Object { $_.Endpoint -eq $ep } - if ($ok) { - Log "TCP OK ${ep}:443 (${ms}ms)" OK - if ($rr3) { $rr3.TCP = 'OK'; $rr3.Latency = "${ms}ms" } + $coreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) + $pkiWarnOnly = ($group -eq 'PKI' -and $coreHealthyNoCritical) + if ($script:ProxyMode -eq 'ExplicitProxy' -and $type -eq 'PUB' -and $Mode -ne 'Private') { + $tcpState = 'WARN' + $tcpNote = 'Direct TCP to endpoint failed, but explicit proxy is configured.' + $script:Stats.TCPWarn++ + } + elseif (Test-IsOptionalEndpoint -Endpoint $endpoint -Group $group) { + $tcpState = 'WARN' + $tcpNote = 'Optional endpoint did not accept direct TCP 443 during this run; treat as informational unless azcmagent reports core failures.' + $script:Stats.TCPWarn++ + } + elseif ($pkiWarnOnly) { + $tcpState = 'WARN' + $tcpNote = 'PKI endpoint did not accept direct TCP 443 during this run; treat as informational while Arc core remains healthy.' + $script:Stats.TCPWarn++ + Add-Issue -Severity 'WARN' -Category 'PKI' -Message "PKI endpoint ${endpoint}:443 was not reachable." -Fix 'Review PKI/CRL/OCSP reachability only if certificate validation is the target symptom; do not treat this alone as Arc runtime failure when azcmagent reports critical_failures=0.' } else { - # In proxy scenarios, TCP fail may be expected (proxy handles L7) - if ($script:EffectiveProxy -and $grp -ne 'PKI') { - Log "TCP WARN ${ep}:443 (proxy may handle)" Warn - if ($rr3) { $rr3.TCP = 'WARN'; $rr3.Latency = 'proxy' } - } - else { - Log "TCP FAIL ${ep}:443" Fail - if ($rr3) { $rr3.TCP = 'FAIL'; $rr3.Latency = 'timeout' } - Add-Issue -Sev 'HIGH' -Cat 'TCP' -Msg "Cannot connect to ${ep}:443" -Fix 'Check firewall/proxy rules' - } + $tcpState = 'FAIL' + $tcpNote = 'TCP 443 failed.' + $script:Stats.TCPFail++ + Add-Issue -Severity 'HIGH' -Category 'TCP' -Message "Cannot connect to ${endpoint}:443." -Fix 'Validate routing, firewall, or proxy path expectations for this endpoint.' } } + Add-Result -Endpoint $endpoint -TCP $tcpState -Latency $(if ($tcpOk) { "${latency}ms" } else { 'timeout' }) -Notes $tcpNote } -Write-Host '' # Clear progress line - -# ========================================================================= -# PHASE 7b: HTTP TESTS + PKI PROBE -# ========================================================================= +Write-Host '' -foreach ($ep in $httpProbeEps) { - $ep = $ep.Trim() - if (-not $ep) { continue } - if ($httpBypassedEps -contains $ep) { - Add-Result -Endpoint $ep -HTTP 'SKIP' - Log "HTTP SKIP $ep (bypass)" Info -NoCount - continue +Write-Section 'HTTP Probes' +foreach ($endpoint in $httpProbeEndpoints) { + $httpBypassApplies = $false + $endpointHost = $endpoint.Trim().ToLower() + foreach ($entry in $httpBypassedEndpoints) { + if (-not $entry) { continue } + $norm = $entry.Trim().ToLower() + if (-not $norm) { continue } + if ($norm.StartsWith('*.')) { $norm = $norm.Substring(1) } + if ($endpointHost -eq $norm.TrimStart('.') -or ($norm.StartsWith('.') -and $endpointHost.EndsWith($norm))) { + $httpBypassApplies = $true + break + } } - try { - $resp = Invoke-HttpSafe -Uri "https://$ep" -Timeout 10 - Log "HTTP OK $ep -> $($resp.StatusCode)" OK - Add-Result -Endpoint $ep -HTTP "OK($($resp.StatusCode))" + if ($httpBypassApplies) { + Add-Result -Endpoint $endpoint -HTTP 'SKIP' -Notes 'Skipped because agent bypass category applies.' + continue } - catch { - $code = $null - if ($_.Exception.Response) { - try { $code = [int]$_.Exception.Response.StatusCode } catch { } - } - # Any HTTP response (even 4xx/5xx) means network + TLS worked - if ($code -and $code -ge 100 -and $code -lt 600) { - Log "HTTP OK $ep -> $code (endpoint reachable)" OK - Add-Result -Endpoint $ep -HTTP "OK($code)" - } - else { - Log "HTTP FAIL $ep" Fail - Add-Result -Endpoint $ep -HTTP 'FAIL' - Add-Issue -Sev 'MEDIUM' -Cat 'HTTP' -Msg "HTTP failed: $ep" -Fix 'Check proxy/firewall app rules' - } + + $forceDirect = $false + if ($endpoint -eq 'oneocsp.microsoft.com' -and $script:WinHttpProxy) { + $forceDirect = Test-IsPkiCoveredByBypassOnly -Endpoint 'oneocsp.microsoft.com' } -} -# PKI HTTP probe — tests the REAL path SCHANNEL will use: -# If oneocsp.microsoft.com is in bypass → test DIRECT (no proxy) -# If NOT in bypass → test via WinHTTP proxy (likely fails on explicit proxy) + $group = if ($endpointGroupMap.ContainsKey($endpoint)) { $endpointGroupMap[$endpoint] } else { 'Core' } + $coreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) + $privatePathHealthyObserved = ( + $script:AzcmagentPrivatePathHealthy -or + @( + $script:AzcmagentEndpointMeta.Values | Where-Object { + $_.Reachable -eq $true -and ($privateEligible -contains $_.HostName) -and ( + $_.Private -eq 'true' -or + ($_.ProxyStatus -eq 'bypassed' -and $_.HostName -match 'his\.arc\.azure\.com|guestconfiguration\.azure\.com') + ) + } + ).Count -gt 0 -or + @( + $script:Results | Where-Object { + ($privateEligible -contains $_.Endpoint) -and $_.DNS -eq 'OK' -and $_.TCP -eq 'OK' + } + ).Count -gt 0 + ) + $optionalEndpoint = Test-IsOptionalEndpoint -Endpoint $endpoint -Group $group -# --- SQL Arc TLS 1.2 probe --- -# Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint#check-tls-version-compatibility -# arcdataservices.com REQUIRES TLS 1.2+ and GCM ciphers. Server 2012 (non-R2) will fail here. -if ($extEps.ContainsKey('SQL')) { - $sqlTlsTarget = "dataprocessingservice.$Region.arcdataservices.com" - $sqlTlsOk = $false - $savedSqlProto = [Net.ServicePointManager]::SecurityProtocol - try { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $sqlReq = [System.Net.HttpWebRequest]::Create("https://$sqlTlsTarget") - $sqlReq.Timeout = 10000 - $sqlReq.Method = 'HEAD' - if ($script:EffectiveProxy) { - $sqlReq.Proxy = [System.Net.WebProxy]::new($script:EffectiveProxy) - $sqlReq.Proxy.UseDefaultCredentials = $true - } elseif ($PSVersionTable.PSVersion.Major -lt 6) { - $sqlReq.Proxy = $null - } - $sqlResp = $sqlReq.GetResponse() - $sqlResp.Close() - $sqlTlsOk = $true + $http = Get-HttpStatus -Endpoint $endpoint -ForceDirect:$forceDirect + if ($http.Success) { + $script:Stats.HTTPOK++ + Add-Result -Endpoint $endpoint -HTTP ("OK($($http.StatusCode))") } - catch { - $sqlErr = $_.Exception.Message - # In PS 5.1, .GetResponse() wraps WebException in MethodInvocationException - $innerEx = if ($_.Exception.InnerException) { $_.Exception.InnerException } else { $_.Exception } - # If we got an HTTP response (4xx, 5xx), TLS handshake succeeded - if ($innerEx -is [System.Net.WebException]) { - $wex = [System.Net.WebException]$innerEx - if ($wex.Response) { - # Any HTTP response means TLS worked (endpoint just doesn't accept GET/HEAD) - $sqlTlsOk = $true + else { + $httpDiagnosis = Get-HttpFailureDiagnosis -Endpoint $endpoint -Group $group -ErrorText $http.Error -StatusCode $http.StatusCode -UsingExplicitProxy:($script:ProxyMode -eq 'ExplicitProxy') -ForceDirect:$forceDirect -CoreHealthyNoCritical:$coreHealthyNoCritical + $privateControlPlaneProxyWarn = ($Mode -eq 'Private' -and $endpoint -eq 'management.azure.com' -and $privatePathHealthyObserved -and $coreHealthyNoCritical) + if ($privateControlPlaneProxyWarn) { + $httpDiagnosis = [pscustomobject]@{ + Cause = 'split-network control-plane proxy path degraded' + Notes = 'Private mode with Arc private path healthy; Azure Resource Manager traffic failed on the current control-plane/proxy path. Azure Arc Private Link Scope does not carry ARM traffic by default, so this matches a split-network pattern unless onboarding or ARM operations are the target symptom.' + Fix = 'Validate proxy reachability to Azure Resource Manager and confirm split-network intent. If ARM must stay private, configure Resource Management Private Link separately. Treat this as non-blocking for Arc runtime when azcmagent reports critical_failures=0 and private Arc endpoints are reachable.' + Category = 'ControlPlane' + ProxyConfiguredButDown = $httpDiagnosis.ProxyConfiguredButDown + } + } + if ($privateControlPlaneProxyWarn -and $coreHealthyNoCritical) { + $script:Stats.HTTPWarn++ + $httpWarnCategory = 'ControlPlane' + $httpWarnGroup = 'ControlPlane' + $httpWarnNotes = $httpDiagnosis.Notes + $httpWarnMessage = 'management.azure.com failed via proxy/control-plane path while Arc private-capable endpoints remained healthy.' + $httpWarnFix = $httpDiagnosis.Fix + Add-Result -Endpoint $endpoint -Group $httpWarnGroup -HTTP $(if ($http.StatusCode) { "WARN($($http.StatusCode))" } else { 'WARN' }) -Notes $httpWarnNotes + Add-Issue -Severity 'WARN' -Category $httpWarnCategory -Message $httpWarnMessage -Fix $httpWarnFix + } + elseif ($optionalEndpoint) { + $script:Stats.HTTPWarn++ + $optionalHttpNotes = 'Optional endpoint failed HTTP probe; treat as informational unless core also fails.' + $optionalHttpMessage = "Optional HTTP probe to $endpoint failed; likely $($httpDiagnosis.Cause)." + $optionalHttpFix = 'Review this endpoint only if the related optional extension or feature is in use.' + if (Test-IsArcDataTlsPathError -Endpoint $endpoint -ErrorText $http.Error) { + $optionalHttpNotes = 'Arc Data endpoint returned a malformed TLS message; likely proxy/TLS inspection or path-specific handshake issue, not a general TLS 1.2 limitation.' + $optionalHttpMessage = "Arc Data HTTP probe to $endpoint failed with a malformed TLS message signature." + $optionalHttpFix = 'Review TLS inspection, reverse proxy, firewall, or middlebox behavior on the Arc Data path. If core azcmagent endpoints remain healthy, do not treat this alone as host TLS incompatibility.' } - elseif ($wex.Status -eq [System.Net.WebExceptionStatus]::SecureChannelFailure -or - $wex.Status -eq [System.Net.WebExceptionStatus]::TrustFailure) { - # Explicit TLS failure - $sqlTlsOk = $false + elseif ($httpDiagnosis.Notes) { + $optionalHttpNotes = $httpDiagnosis.Notes + $optionalHttpFix = $httpDiagnosis.Fix } - elseif ($wex.Status -eq [System.Net.WebExceptionStatus]::ConnectFailure -or - $wex.Status -eq [System.Net.WebExceptionStatus]::Timeout) { - # Network issue, not TLS-specific - $sqlTlsOk = $false - $sqlErr = "Network: $($wex.Status) - $sqlErr" + Add-Result -Endpoint $endpoint -HTTP $(if ($http.StatusCode) { "WARN($($http.StatusCode))" } else { 'WARN' }) -Notes $optionalHttpNotes + Add-Issue -Severity 'WARN' -Category 'HTTP' -Message $optionalHttpMessage -Fix $optionalHttpFix + } + elseif ($script:ProxyMode -eq 'ExplicitProxy' -and $forceDirect -eq $false -and $coreHealthyNoCritical) { + $script:Stats.HTTPWarn++ + $pkiProxyWarnOnly = ($Mode -eq 'Private' -and $group -eq 'PKI' -and $endpoint -eq 'oneocsp.microsoft.com' -and $privatePathHealthyObserved) + $httpWarnNotes = if ($pkiProxyWarnOnly) { + 'PKI/OCSP endpoint failed over the current proxy-routed path while Arc private-capable endpoints remained healthy. Treat as a PKI revocation-path warning, not Arc runtime failure.' } else { - # Other WebException — connection was established (TLS likely OK) - # ReceiveFailure, ProtocolError without Response, etc. - $sqlTlsOk = $true + $httpDiagnosis.Notes } - } - elseif ($_.Exception.Response -or ($innerEx -and $innerEx.Response)) { - # Got HTTP response through another exception wrapper - $sqlTlsOk = $true - } - } - finally { - [Net.ServicePointManager]::SecurityProtocol = $savedSqlProto - } - - if ($sqlTlsOk) { - Log "SQL TLS probe OK: $sqlTlsTarget (TLS 1.2 handshake succeeded)" OK - Add-Result -Endpoint $sqlTlsTarget -HTTP 'OK(TLS)' - } - else { - Log "SQL TLS probe FAIL: $sqlTlsTarget - $sqlErr" Fail - Add-Result -Endpoint $sqlTlsTarget -HTTP 'FAIL(TLS)' - Add-Issue -Sev 'HIGH' -Cat 'SQL TLS' ` - -Msg "TLS 1.2 handshake failed to $sqlTlsTarget. SQL Arc telemetry will not work." ` - -Fix 'Ref: https://learn.microsoft.com/sql/sql-server/azure-arc/troubleshoot-telemetry-endpoint#check-tls-version-compatibility' - } -} - -if (-not $SkipPKI -and $script:WinHttpProxy) { - $uncPkiNow = Test-PkiBypassCoverage - $ocspBypassed = $uncPkiNow -notcontains 'oneocsp.microsoft.com' - - if ($ocspBypassed) { - # Endpoint is in bypass — SCHANNEL will connect DIRECT, not via proxy - try { - $probeParams = @{ - Uri = 'http://oneocsp.microsoft.com' - Method = 'Get'; UseBasicParsing = $true - TimeoutSec = 5; ErrorAction = 'Stop' + $httpWarnCategory = if ($pkiProxyWarnOnly) { 'PKI' } else { $httpDiagnosis.Category } + $httpWarnGroup = if ($privateControlPlaneProxyWarn) { 'ControlPlane' } elseif ($pkiProxyWarnOnly) { 'PKI' } else { $group } + $httpWarnMessage = if ($privateControlPlaneProxyWarn) { + 'management.azure.com failed via proxy/control-plane path while Arc private-capable endpoints remained healthy.' } - if ($PSVersionTable.PSVersion.Major -ge 6) { - $probeParams['NoProxy'] = $true + elseif ($pkiProxyWarnOnly) { + 'oneocsp.microsoft.com failed over the proxy-routed path; treat this as a PKI/OCSP warning unless certificate revocation checks are the target symptom.' } else { - $savedWP = [System.Net.WebRequest]::DefaultWebProxy - [System.Net.WebRequest]::DefaultWebProxy = $null + "HTTP probe to $endpoint failed via proxy path; likely $($httpDiagnosis.Cause)." } - $resp = Invoke-WebRequest @probeParams - if ($PSVersionTable.PSVersion.Major -lt 6) { - [System.Net.WebRequest]::DefaultWebProxy = $savedWP - } - Log "PKI probe OK direct (bypass active)" OK - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($($resp.StatusCode))" - } - catch { - try { if ($PSVersionTable.PSVersion.Major -lt 6) { [System.Net.WebRequest]::DefaultWebProxy = $savedWP } } catch { } - # OCSP responders return 4xx on bare GET — any HTTP response = reachable - $code = $null - try { - if ($_.Exception -and $_.Exception.Response) { - $code = [int]$_.Exception.Response.StatusCode - } - } catch { } - if ($code -and $code -ge 200 -and $code -lt 600) { - Log "PKI probe OK direct -> $code (bypass active)" OK - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($code)" + $httpWarnFix = if ($pkiProxyWarnOnly) { + 'Validate OCSP/CRL reachability and any proxy exception or inspection policy that affects revocation traffic. Do not treat this alone as Arc core failure while azcmagent reports no critical connectivity failures.' } else { - $msg = $_.Exception.Message - Log "PKI probe FAIL direct: $msg" Fail - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'FAIL' - Add-Issue -Sev 'HIGH' -Cat 'PKI Direct' ` - -Msg 'OCSP unreachable via direct path (bypass active but no route)' ` - -Fix 'Ensure firewall network/application rules allow direct HTTP:80 to PKI endpoints' + $httpDiagnosis.Fix } + if ($httpWarnCategory -ne 'Proxy' -and $httpWarnCategory -ne 'ControlPlane' -and $httpWarnFix -and $httpWarnFix -notmatch 'critical_failures=0' -and $httpWarnFix -notmatch 'no critical connectivity failures') { + $httpWarnFix = ($httpWarnFix.TrimEnd('.') + '. Do not treat this alone as Arc core failure while azcmagent reports no critical connectivity failures.') + } + Add-Result -Endpoint $endpoint -Group $httpWarnGroup -HTTP $(if ($http.StatusCode) { "WARN($($http.StatusCode))" } else { 'WARN' }) -Notes $httpWarnNotes + Add-Issue -Severity 'WARN' -Category $httpWarnCategory -Message $httpWarnMessage -Fix $httpWarnFix } - } - else { - # Endpoint NOT in bypass — SCHANNEL sends via proxy (will likely fail on explicit proxy) - try { - $resp = Invoke-HttpSafe -Uri 'http://oneocsp.microsoft.com' -Timeout 5 -UseProxy $script:WinHttpProxy - Log "PKI probe OK via WinHTTP proxy" OK - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP "OK($($resp.StatusCode))" - } - catch { - $msg = $_.Exception.Message - if ($msg -match '407') { - Log "PKI probe: 407 proxy auth" Warn - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'WARN' + elseif ($script:ProxyMode -eq 'ExplicitProxy' -and $forceDirect -eq $false) { + $script:Stats.HTTPWarn++ + $httpWarnGroup = if ($privateControlPlaneProxyWarn) { 'ControlPlane' } else { $group } + $httpWarnMessage = if ($privateControlPlaneProxyWarn) { + 'management.azure.com failed via proxy/control-plane path while Arc private-capable endpoints remained healthy.' } else { - Log "PKI probe FAIL via proxy: $msg" Fail - Add-Result -Endpoint 'oneocsp.microsoft.com' -HTTP 'FAIL' - Add-Issue -Sev 'CRITICAL' -Cat 'PKI Proxy' ` - -Msg 'OCSP unreachable via WinHTTP proxy (non-proxy request on proxy port)' ` - -Fix 'Add PKI endpoints to proxy bypass (GPO NO_PROXY)' + "HTTP probe to $endpoint failed via proxy path; likely $($httpDiagnosis.Cause)." } + Add-Result -Endpoint $endpoint -Group $httpWarnGroup -HTTP $(if ($http.StatusCode) { "WARN($($http.StatusCode))" } else { 'WARN' }) -Notes $httpDiagnosis.Notes + Add-Issue -Severity 'WARN' -Category $httpDiagnosis.Category -Message $httpWarnMessage -Fix $httpDiagnosis.Fix + } + else { + $script:Stats.HTTPFail++ + Add-Result -Endpoint $endpoint -HTTP $(if ($http.StatusCode) { "FAIL($($http.StatusCode))" } else { 'FAIL' }) -Notes $httpDiagnosis.Notes + Add-Issue -Severity 'MEDIUM' -Category $httpDiagnosis.Category -Message "HTTP probe to $endpoint failed; likely $($httpDiagnosis.Cause)." -Fix $httpDiagnosis.Fix } } } -# azcmagent check result (already ran during discovery) -if ($null -ne $script:AzcmagentCheckExit) { - if ($script:AzcmagentCheckExit -eq 0) { Log 'azcmagent check: exit 0' OK } - else { Log "azcmagent check: exit $($script:AzcmagentCheckExit)" Fail } -} -elseif (-not $azcm) { - Log 'azcmagent not found - skipping check' Warn +Run-PlatformChecks -DetectedPlatform $Platform + +$coreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) +if ($coreHealthyNoCritical) { + Convert-IssuesToNonBlockingWarnings } Save-Log -# ========================================================================= -# PHASE 8: RESULTS -# ========================================================================= - -Write-Banner 'PHASE 3: RESULTS' - -$tbl = $script:Results | ForEach-Object { [pscustomobject]$_ } - -# Sort by group priority -$grps = $tbl | Group-Object Group | Sort-Object @{ Expression = { - switch ($_.Name) { - 'Core' { 0 }; 'PKI' { 1 }; 'SQL' { 2 }; 'AMA' { 3 }; 'MDE' { 4 } - 'WAC' { 5 }; 'KV' { 6 }; 'HRW' { 7 }; 'UM' { 8 }; 'GA' { 9 } - 'GNS' { 10 }; default { 11 } +Write-Banner 'DISCLAIMER' +foreach ($line in $script:DisclaimerLines) { + Write-Host (" {0}" -f $line) -ForegroundColor DarkGray +} + +Write-Banner 'RESULTS' +$results = $script:Results | Sort-Object @{ Expression = { + switch ($_.Group) { + 'Core' { 0 } + 'PKI' { 1 } + 'SQL' { 2 } + 'AMA' { 3 } + 'MDE' { 4 } + 'WAC' { 5 } + 'KV' { 6 } + 'HRW' { 7 } + 'UM' { 8 } + 'GA' { 9 } + 'GNS' { 10 } + default { 11 } + } +} }, Endpoint + +$fmt = " {0,-5} | {1,-50} | {2,-16} | {3,-4} | {4,-8} | {5,-8} | {6,-10} | {7,-7}" +Write-Host ($fmt -f 'Group', 'Endpoint', 'IP', 'Type', 'DNS', 'TCP', 'HTTP', 'Latency') -ForegroundColor Cyan +Write-Host (" {0,-5}-+-{1,-50}-+-{2,-16}-+-{3,-4}-+-{4,-8}-+-{5,-8}-+-{6,-10}-+-{7,-7}" -f ('-' * 5), ('-' * 50), ('-' * 16), ('-' * 4), ('-' * 8), ('-' * 8), ('-' * 10), ('-' * 7)) -ForegroundColor DarkGray +foreach ($r in $results) { + $isFail = $r.DNS -eq 'FAIL' -or $r.TCP -eq 'FAIL' -or $r.HTTP -like 'FAIL*' + $isWarn = $r.DNS -eq 'WARN' -or $r.TCP -eq 'WARN' -or $r.HTTP -like 'WARN*' + $color = if ($isFail) { 'Red' } elseif ($isWarn) { 'Yellow' } else { 'Green' } + Write-Host ($fmt -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) -ForegroundColor $color + if (-not [string]::IsNullOrWhiteSpace($r.Notes)) { + Write-Host (" Notes: {0}" -f $r.Notes) -ForegroundColor DarkGray + } +} + +if ($wildcardEndpoints.Count -gt 0) { + Write-Host '' + Write-Host ' Wildcard endpoints (informational only):' -ForegroundColor DarkGray + foreach ($w in $wildcardEndpoints) { + $g = if ($endpointGroupMap.ContainsKey($w)) { $endpointGroupMap[$w] } else { '-' } + Write-Host (" [{0}] {1}" -f $g, $w) -ForegroundColor DarkGray } -} } - -# Pipe-delimited table (azcmagent check style) -$hf = " {0,-5} | {1,-46} | {2,-16} | {3,-4} | {4,-6} | {5,-9} | {6,-7}" -Write-Host ($hf -f 'Group', 'Endpoint', 'IP', 'Type', 'Path', 'Result', 'Latency') -ForegroundColor Cyan -Write-Host (" {0,-5}-+-{1,-46}-+-{2,-16}-+-{3,-4}-+-{4,-6}-+-{5,-9}-+-{6,-7}" -f ` - ('-' * 5), ('-' * 46), ('-' * 16), ('-' * 4), ('-' * 6), ('-' * 9), ('-' * 7)) -ForegroundColor DarkGray - -foreach ($g in $grps) { - foreach ($r in $g.Group) { - $dnsOk = $r.DNS -notin @('FAIL', 'WARN', '-') - $tcpOk = $r.TCP -notin @('FAIL', '-') - $httpOk = ($r.HTTP -eq '-') -or ($r.HTTP -like 'OK*') -or ($r.HTTP -eq 'SKIP') - $fail = ($r.DNS -eq 'FAIL') -or ($r.TCP -eq 'FAIL') -or ($r.HTTP -like 'FAIL*') - $warn = ($r.DNS -eq 'WARN') -or ($r.TCP -eq 'WARN') -or ($r.HTTP -like 'WARN*') +} - # Compose result column (mimics azcmagent: Reachable / Unreachable / Warning) - if ($fail) { - $detail = @() - if ($r.DNS -eq 'FAIL') { $detail += 'DNS' } - if ($r.TCP -eq 'FAIL') { $detail += 'TCP' } - if ($r.HTTP -like 'FAIL*') { $detail += 'HTTP' } - $result = "FAIL($($detail -join ','))" +if ($script:Issues.Count -gt 0) { + Write-Banner ("ISSUES ($($script:Issues.Count))") + $ifmt = " {0,-3} | {1,-8} | {2,-14} | {3}" + Write-Host ($ifmt -f '#', 'Severity', 'Category', 'Message') -ForegroundColor Cyan + Write-Host (" {0,-3}-+-{1,-8}-+-{2,-14}-+-{3}" -f ('-' * 3), ('-' * 8), ('-' * 14), ('-' * 50)) -ForegroundColor DarkGray + $i = 0 + foreach ($issue in $script:Issues) { + $i++ + $color = switch ($issue.Severity) { + 'CRITICAL' { 'Red' } + 'HIGH' { 'Red' } + 'MEDIUM' { 'Yellow' } + 'WARN' { 'Yellow' } + default { 'Gray' } } - elseif ($warn) { - $result = 'Warning' + Write-Host ($ifmt -f $i, $issue.Severity, $issue.Category, $issue.Message) -ForegroundColor $color + if ($issue.Fix) { + Write-Host (" {0,-3} | {1,-8} | {2,-14} | Fix: {3}" -f '', '', '', $issue.Fix) -ForegroundColor DarkCyan } - elseif ($r.TCP -eq 'TUNNEL') { - $result = 'Tunneled' + } +} + +Write-Host '' +Write-Host ('=' * 82) -ForegroundColor DarkCyan +$criticalIssues = @($script:Issues | Where-Object { $_.Severity -in @('CRITICAL', 'HIGH') }) +$warningIssues = @($script:Issues | Where-Object { $_.Severity -in @('MEDIUM', 'WARN') }) +$coreHealthyNoCritical = (($script:AzcmagentCoreHealthy -eq $true) -or (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -eq 0)) +$coreBlockingFails = @( + $results | Where-Object { + $_.Group -eq 'Core' -and ( + $_.DNS -eq 'FAIL' -or + $_.TCP -eq 'FAIL' -or + ($_.HTTP -like 'FAIL*' -and -not ($script:ProxyMode -eq 'ExplicitProxy' -and $_.Endpoint -eq 'management.azure.com')) + ) + } +) +$blockingCriticalIssues = @( + $criticalIssues | Where-Object { + -not ($coreHealthyNoCritical -and $_.Category -in @('Proxy', 'AgentCheck')) + } +) +$nonGnsResultWarnings = @( + $results | Where-Object { + $_.Group -ne 'GNS' -and ( + $_.DNS -eq 'WARN' -or + $_.TCP -eq 'WARN' -or + $_.HTTP -like 'WARN*' + ) + } +) +$hasFail = ($blockingCriticalIssues.Count -gt 0) -or ($coreBlockingFails.Count -gt 0) +$hasWarn = ($warningIssues.Count -gt 0) -or ($nonGnsResultWarnings.Count -gt 0) -or (($criticalIssues.Count -gt 0) -and ($blockingCriticalIssues.Count -lt $criticalIssues.Count)) +if ($coreHealthyNoCritical -and $coreBlockingFails.Count -eq 0) { + $hasFail = $false + $hasWarn = (($warningIssues.Count -gt 0) -or ($nonGnsResultWarnings.Count -gt 0) -or (($criticalIssues.Count -gt 0) -and ($blockingCriticalIssues.Count -lt $criticalIssues.Count))) +} +$status = if ($hasFail) { 'FAIL' } elseif ($hasWarn) { 'WARN' } else { 'PASS' } +$statusColor = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } +$script:ScenarioSummary = @() +$legacyTls13Endpoints = @( + $script:AzcmagentEndpointMeta.Values | Where-Object { + $_.Tls -and $_.Tls -match 'TLS\s*1\.3' + } | Select-Object -ExpandProperty HostName -Unique +) +$controlPlaneDegraded = @( + $results | Where-Object { + $_.Group -eq 'ControlPlane' -and ( + $_.DNS -in @('WARN', 'FAIL') -or + $_.TCP -in @('WARN', 'FAIL') -or + $_.HTTP -like 'WARN*' -or + $_.HTTP -like 'FAIL*' + ) + } +).Count -gt 0 +$optionalDegraded = @( + $results | Where-Object { + (Test-IsOptionalEndpoint -Endpoint $_.Endpoint -Group $_.Group) -and ( + $_.DNS -in @('WARN', 'FAIL') -or + $_.TCP -in @('WARN', 'FAIL') -or + $_.HTTP -like 'WARN*' -or + $_.HTTP -like 'FAIL*' + ) + } +).Count -gt 0 +$pkiDegraded = @( + $results | Where-Object { + $_.Group -eq 'PKI' -and ( + $_.DNS -in @('WARN', 'FAIL') -or + $_.TCP -in @('WARN', 'FAIL') -or + $_.HTTP -like 'WARN*' -or + $_.HTTP -like 'FAIL*' + ) + } +).Count -gt 0 +$runtimeState = if ($coreHealthyNoCritical -and $coreBlockingFails.Count -eq 0) { 'Healthy' } elseif ($coreBlockingFails.Count -gt 0 -or $blockingCriticalIssues.Count -gt 0) { 'Degraded' } else { 'Unknown' } +$controlPlaneState = if ($controlPlaneDegraded) { 'Degraded' } elseif ($script:PreOnboarding) { 'RequiredForOnboarding' } else { 'HealthyOrNotTargeted' } +$optionalState = if ($optionalDegraded) { 'WarningsPresent' } else { 'HealthyOrNotInUse' } +$pkiState = if ($pkiDegraded) { 'WarningsPresent' } else { 'HealthyOrNotObserved' } +$modeInterpretation = switch ($Mode) { + 'Private' { + if ($script:AzcmagentPrivatePathHealthy -and $coreHealthyNoCritical) { + 'Private path validated; Arc runtime is using or observing private-capable endpoints successfully.' + } + else { + 'Private mode selected; validate private DNS and Private Link Scope behavior for Arc-capable endpoints.' } - elseif ($r.HTTP -eq 'SKIP') { - $result = 'Reachable*' + } + 'Gateway' { + if ($script:GatewayUrl) { + "Gateway mode in effect via $($script:GatewayUrl)." } else { - $result = 'Reachable' + 'Gateway mode in effect; validate the local listener and upstream proxy path together.' } - - $c = if ($fail) { 'Red' } elseif ($warn) { 'Yellow' } elseif ($r.TCP -eq 'TUNNEL') { 'DarkYellow' } else { 'Green' } - $pathCol = if ($r.Path) { $r.Path } else { '-' } - Write-Host ($hf -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $pathCol, $result, $r.Latency) -ForegroundColor $c + } + default { + 'Public/direct pattern in effect unless a proxy or private DNS override changes the observed path.' } } - -# Wildcard endpoints (informational) -if ($wildcardEps.Count -gt 0) { - Write-Host '' - $wFmt = " {0,-5} | {1,-50} | {2}" - Write-Host ($wFmt -f 'Group', 'Wildcard Endpoint', 'Note') -ForegroundColor DarkGray - Write-Host (" {0,-5}-+-{1,-50}-+-{2}" -f ('-' * 5), ('-' * 50), ('-' * 30)) -ForegroundColor DarkGray - foreach ($w in $wildcardEps) { - $wg = if ($endpointGroupMap.ContainsKey($w)) { $endpointGroupMap[$w] } else { '-' } - Write-Host ($wFmt -f $wg, $w, 'Requires firewall rule (not testable)') -ForegroundColor DarkGray +if ($coreHealthyNoCritical) { + $script:ScenarioSummary += 'Arc core healthy (azcmagent reported no critical connectivity failures).' +} +elseif (($null -ne $script:AzcmagentCriticalFailures) -and $script:AzcmagentCriticalFailures -gt 0) { + if ($script:AzcmagentFailedEndpoints.Count -gt 0) { + $script:ScenarioSummary += ('azcmagent reported required Arc connectivity failures for: ' + (($script:AzcmagentFailedEndpoints | Select-Object -Unique) -join ', ') + '.') + } + else { + $script:ScenarioSummary += 'azcmagent reported required Arc connectivity failures to one or more endpoints.' } } +$privateControlPlaneWarnOnly = ($Mode -eq 'Private' -and $coreHealthyNoCritical -and @($results | Where-Object { + ($privateEligible -contains $_.Endpoint) -and $_.DNS -eq 'OK' -and $_.TCP -eq 'OK' +}).Count -gt 0 -and @($results | Where-Object { $_.Endpoint -eq 'management.azure.com' -and ($_.HTTP -like 'WARN*' -or $_.HTTP -like 'FAIL*') }).Count -gt 0) +$proxyWarnResults = @($results | Where-Object { $_.HTTP -like 'WARN*' -or ($_.Endpoint -eq 'management.azure.com' -and $_.HTTP -like 'FAIL*') }) +$proxyWarnEndpoints = @($proxyWarnResults | ForEach-Object { $_.Endpoint } | Where-Object { $_ } | Select-Object -Unique) +$proxyWarnOnlyArcData = ($proxyWarnEndpoints.Count -gt 0 -and @($proxyWarnEndpoints | Where-Object { Test-IsArcDataEndpoint -Endpoint $_ }).Count -eq $proxyWarnEndpoints.Count) +if ($privateControlPlaneWarnOnly) { + $modeInterpretation = 'Private split-network pattern observed; Arc private endpoints are healthy, while Azure Resource Manager is failing on the separate proxy/control-plane path.' + $script:ScenarioSummary += 'Private/split-network pattern observed: Arc private-capable endpoints remained healthy while the ARM control-plane path was degraded. Azure Arc Private Link Scope does not include ARM traffic by default.' +} +elseif ($script:ProxyMode -eq 'ExplicitProxy' -and $proxyWarnResults.Count -gt 0) { + if ($coreHealthyNoCritical -and -not $controlPlaneDegraded -and $proxyWarnOnlyArcData) { + $script:ScenarioSummary += 'Explicit proxy path showed warnings only for optional SQL/Arc data endpoints (DPS inventory/billing upload and telemetry/log/DMV upload); Arc core connectivity remained healthy.' + } + else { + $script:ScenarioSummary += 'Proxy path degraded for one or more endpoints.' + } -# ========================================================================= -# PHASE 9: ISSUES -# ========================================================================= - -if ($script:Issues.Count -gt 0) { - Write-Banner "ISSUES ($($script:Issues.Count))" - $iFmt = " {0,-3} | {1,-8} | {2,-12} | {3}" - Write-Host ($iFmt -f '#', 'Severity', 'Category', 'Message') -ForegroundColor Cyan - Write-Host (" {0,-3}-+-{1,-8}-+-{2,-12}-+-{3}" -f ('-' * 3), ('-' * 8), ('-' * 12), ('-' * 50)) -ForegroundColor DarkGray - $ix = 0 - foreach ($iss in $script:Issues) { - $ix++ - $sc = switch ($iss.Severity) { - 'CRITICAL' { 'Red' }; 'HIGH' { 'Red' } - 'MEDIUM' { 'Yellow' }; 'WARN' { 'Yellow' } - default { 'Gray' } - } - Write-Host ($iFmt -f $ix, $iss.Severity, $iss.Category, $iss.Message) -ForegroundColor $sc - if ($iss.Fix) { - Write-Host (" {0,-3} | {1,-8} | {2,-12} | Fix: {3}" -f '', '', '', $iss.Fix) -ForegroundColor DarkCyan - } + $proxyFailureSignatures = @( + $script:Issues | Where-Object { + $_.Category -in @('Proxy', 'ProxyPath', 'HTTP', 'ControlPlane') -and $_.Message -match 'likely ' + } | ForEach-Object { + if ($_.Message -match 'likely ([^.]+)') { $Matches[1].Trim() } + } | Where-Object { $_ } | Select-Object -Unique + ) + if ($proxyFailureSignatures.Count -gt 0) { + $script:ScenarioSummary += ('Observed proxy-path failure signature(s): ' + ($proxyFailureSignatures -join ', ') + '.') } } - -# ========================================================================= -# PHASE 10: FINAL SUMMARY -# ========================================================================= - -Write-Host '' -Write-Host ('=' * 74) -ForegroundColor DarkCyan - -$fc = $script:Stats.Fail -$wc = $script:Stats.Warn -$oc = $script:Stats.OK -$ic = $script:Issues.Count - -# Only CRITICAL/HIGH issues cause FAIL; WARN/MEDIUM issues cause WARN status but exit 0 -$critIssues = @($script:Issues | Where-Object { $_.Severity -in @('CRITICAL', 'HIGH') }) -$hasFail = ($fc -gt 0) -or ($critIssues.Count -gt 0) -$hasWarn = ($wc -gt 0) -or ($ic -gt $critIssues.Count) -$summColor = if ($hasFail) { 'Red' } elseif ($hasWarn) { 'Yellow' } else { 'Green' } -$statusText = if ($hasFail) { 'FAIL' } elseif ($hasWarn) { 'WARN' } else { 'PASS' } - -$gwTag = if ($script:GatewayUrl) { ' [GW]' } else { '' } -$preTag = if ($script:PreOnboarding) { ' [PRE-ONBOARDING]' } else { '' } -Write-Host (" STATUS: {0} | OK:{1} Fail:{2} Warn:{3} Issues:{4} | {5} {6}{7}{8}" -f ` - $statusText, $oc, $fc, $wc, $ic, $Mode, $Region, $gwTag, $preTag) -ForegroundColor $summColor - -if ($script:InstalledExts.Count -gt 0) { - Write-Host " Extensions: $($script:InstalledExts -join ', ')" -ForegroundColor DarkGray +if ($optionalDegraded) { + $script:ScenarioSummary += 'Optional extension/GNS endpoints are degraded; treated as WARN unless core also fails.' } -if ($script:EffectiveProxy) { - Write-Host " Proxy: $($script:EffectiveProxy)" -ForegroundColor DarkGray +if ($pkiDegraded -and $coreHealthyNoCritical) { + $script:ScenarioSummary += 'PKI/CRL/OCSP warnings were observed, but they do not currently prove Arc runtime failure.' +} +if ($legacyTls12OnlyOs -and $legacyTls13Endpoints.Count -gt 0) { + $script:ScenarioSummary += 'azcmagent observed endpoints advertising TLS 1.3; on legacy Windows this is informational as long as TLS 1.2 succeeds for required paths.' } -Write-Host " Log: $LogFilePath" -ForegroundColor DarkGray -Write-Host ('=' * 74) -ForegroundColor DarkCyan +Write-Host (" STATUS: {0} | DNS OK/WARN/FAIL: {1}/{2}/{3} | TCP OK/WARN/FAIL: {4}/{5}/{6} | HTTP OK/WARN/FAIL: {7}/{8}/{9}" -f $status, $script:Stats.DNSOK, $script:Stats.DNSWarn, $script:Stats.DNSFail, $script:Stats.TCPOK, $script:Stats.TCPWarn, $script:Stats.TCPFail, $script:Stats.HTTPOK, $script:Stats.HTTPWarn, $script:Stats.HTTPFail) -ForegroundColor $statusColor +if ($null -ne $script:AzcmagentCriticalFailures) { + Write-Host (" azcmagent summary: checks_failed={0} critical_failures={1} coreHealthy={2}" -f $script:AzcmagentChecksFailed, $script:AzcmagentCriticalFailures, $(if ($script:AzcmagentCoreHealthy) { 'true' } else { 'false' })) -ForegroundColor DarkGray +} +Write-Host (" Interpretation: ArcRuntime={0} | ControlPlane={1} | Optional={2} | PKI={3}" -f $runtimeState, $controlPlaneState, $optionalState, $pkiState) -ForegroundColor DarkGray +Write-Host (" Mode interpretation: {0}" -f $modeInterpretation) -ForegroundColor DarkGray +foreach ($summaryLine in $script:ScenarioSummary) { + Write-Host (" Summary: {0}" -f $summaryLine) -ForegroundColor DarkGray +} +Write-Host (" Scenario: Platform={0} Mode={1} Region={2} Proxy={3}" -f $Platform, $Mode, $Region, $(if ($script:EffectiveProxy) { $script:EffectiveProxy } else { 'Direct' })) -ForegroundColor DarkGray +Write-Host (" Log: {0}" -f $LogFilePath) -ForegroundColor DarkGray +Write-Host ('=' * 82) -ForegroundColor DarkCyan -# --- Append to log file --- -$ts = $tbl | Format-Table -AutoSize | Out-String +Add-Content -Path $LogFilePath -Value '' +Add-Content -Path $LogFilePath -Value '=================== DISCLAIMER ===================' +Add-Content -Path $LogFilePath -Value $script:DisclaimerLines Add-Content -Path $LogFilePath -Value '' Add-Content -Path $LogFilePath -Value '=================== RESULTS ===================' -Add-Content -Path $LogFilePath -Value $ts.TrimEnd() -Add-Content -Path $LogFilePath -Value ("Status: $statusText | OK=$oc Fail=$fc Warn=$wc Issues=$ic | $Mode $Region$gwTag") - -if ($ic -gt 0) { +Add-Content -Path $LogFilePath -Value ($fmt -f 'Group', 'Endpoint', 'IP', 'Type', 'DNS', 'TCP', 'HTTP', 'Latency') +Add-Content -Path $LogFilePath -Value ((" {0,-5}-+-{1,-50}-+-{2,-16}-+-{3,-4}-+-{4,-8}-+-{5,-8}-+-{6,-10}-+-{7,-7}" -f ('-' * 5), ('-' * 50), ('-' * 16), ('-' * 4), ('-' * 8), ('-' * 8), ('-' * 10), ('-' * 7))) +foreach ($r in $results) { + Add-Content -Path $LogFilePath -Value ($fmt -f $r.Group, $r.Endpoint, $r.IP, $r.Type, $r.DNS, $r.TCP, $r.HTTP, $r.Latency) + if (-not [string]::IsNullOrWhiteSpace($r.Notes)) { + Add-Content -Path $LogFilePath -Value ((" Notes: {0}" -f $r.Notes)) + } +} +Add-Content -Path $LogFilePath -Value ("Status: $status | Platform=$Platform Mode=$Mode Region=$Region Proxy=" + $(if ($script:EffectiveProxy) { $script:EffectiveProxy } else { 'Direct' })) +if ($script:Issues.Count -gt 0) { Add-Content -Path $LogFilePath -Value '' Add-Content -Path $LogFilePath -Value '=================== ISSUES ===================' - foreach ($iss in $script:Issues) { - Add-Content -Path $LogFilePath -Value "[$($iss.Severity)] $($iss.Category): $($iss.Message)" - if ($iss.Fix) { Add-Content -Path $LogFilePath -Value " Fix: $($iss.Fix)" } + foreach ($issue in $script:Issues) { + Add-Content -Path $LogFilePath -Value ("[$($issue.Severity)] $($issue.Category): $($issue.Message)") + if ($issue.Fix) { + Add-Content -Path $LogFilePath -Value (" Fix: $($issue.Fix)") + } } }