mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-05-15 09:47:30 +01:00
network connectivity
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Cosmos DB Connectivity Diagnostic Script
|
||||
Captures local network connectivity, private network posture, and RBAC evidence.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs comprehensive network and access diagnostics for Cosmos DB accounts.
|
||||
It can run in interactive or non-interactive mode and produces a JSON report for triage.
|
||||
|
||||
.PARAMETER EndpointUrl
|
||||
The Cosmos DB account endpoint URL.
|
||||
Format: https://<account-name>.documents.azure.com or https://<account-name>.documents.azure.com:443/
|
||||
WHERE TO GET: Azure Portal > Cosmos DB Account > Overview tab > URI field
|
||||
OR: Use the endpoint shown in Cosmos Explorer connection string
|
||||
|
||||
.PARAMETER SubscriptionId
|
||||
Azure subscription ID containing the Cosmos account.
|
||||
WHERE TO GET: Azure Portal > Subscriptions > Copy Subscription ID
|
||||
FORMAT: 12345678-1234-1234-1234-123456789012
|
||||
|
||||
.PARAMETER ResourceGroup
|
||||
Azure resource group name containing the Cosmos account.
|
||||
WHERE TO GET: Azure Portal > Cosmos DB Account > Resource group field (top-right)
|
||||
|
||||
.PARAMETER AccountName
|
||||
Cosmos DB account name.
|
||||
WHERE TO GET: Azure Portal > Cosmos DB Account > Account Name field
|
||||
Or extract from endpoint URL (part before .documents.azure.com)
|
||||
|
||||
.PARAMETER PrivateEndpointIP
|
||||
(Optional) Expected private endpoint IP if account uses private link.
|
||||
WHERE TO GET: Azure Portal > Cosmos DB Account > Private Endpoint Connections tab > Private IP address column
|
||||
|
||||
.PARAMETER VpnSubnetRange
|
||||
(Optional) Customer's VPN/client subnet CIDR for route analysis.
|
||||
FORMAT: 10.0.0.0/24
|
||||
WHERE TO GET: Ask your network team or check VPN client properties
|
||||
|
||||
.PARAMETER Interactive
|
||||
If specified, script prompts for missing parameters instead of requiring them as arguments.
|
||||
|
||||
.PARAMETER Redact
|
||||
If specified, output JSON redacts sensitive identifiers (tenant ID, subscription ID, usernames).
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive mode - script will prompt for inputs
|
||||
.\Diagnose-CosmosConnectivity.ps1 -Interactive
|
||||
|
||||
.EXAMPLE
|
||||
# Non-interactive with full parameters
|
||||
.\Diagnose-CosmosConnectivity.ps1 `
|
||||
-EndpointUrl "https://my-cosmos-account.documents.azure.com" `
|
||||
-SubscriptionId "12345678-1234-1234-1234-123456789012" `
|
||||
-ResourceGroup "my-rg" `
|
||||
-AccountName "my-cosmos-account"
|
||||
|
||||
.EXAMPLE
|
||||
# With private endpoint and output redaction
|
||||
.\Diagnose-CosmosConnectivity.ps1 `
|
||||
-EndpointUrl "https://my-cosmos-account.documents.azure.com" `
|
||||
-SubscriptionId "12345678-1234-1234-1234-123456789012" `
|
||||
-ResourceGroup "my-rg" `
|
||||
-AccountName "my-cosmos-account" `
|
||||
-PrivateEndpointIP "10.123.171.30" `
|
||||
-Redact
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[ValidateScript({$_ -match "^https://[a-z0-9-]+\.documents\.azure\.com" -or $_ -match "^https://[a-z0-9-]+\.documents\.azure\.com:443"})]
|
||||
[string]$EndpointUrl,
|
||||
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[guid]$SubscriptionId,
|
||||
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[string]$ResourceGroup,
|
||||
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[string]$AccountName,
|
||||
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[string]$PrivateEndpointIP,
|
||||
|
||||
[Parameter(ValueFromPipelineByPropertyName=$true)]
|
||||
[string]$VpnSubnetRange,
|
||||
|
||||
[switch]$Interactive,
|
||||
|
||||
[switch]$Redact
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$ScriptVersion = "1.0.0"
|
||||
$DiagnosticTimestamp = Get-Date -Format "o"
|
||||
$TcpConnectTimeoutMs = 5000
|
||||
$DnsTimeoutMs = 5000
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
function Show-InputInstructions {
|
||||
Write-Host @"
|
||||
═════════════════════════════════════════════════════════════════════════════
|
||||
COSMOS DB CONNECTIVITY DIAGNOSTIC SCRIPT v$ScriptVersion
|
||||
═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
This script will collect network and access diagnostics for your Cosmos DB account.
|
||||
|
||||
WHERE TO FIND YOUR INPUTS:
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
1. ENDPOINT URL (Required)
|
||||
Location: Azure Portal > Cosmos DB Account > Overview tab
|
||||
Look for: "URI" field
|
||||
Example: https://my-cosmos-account.documents.azure.com
|
||||
⚠ Include https:// but do NOT include trailing slash or port suffix
|
||||
|
||||
2. SUBSCRIPTION ID (Required)
|
||||
Location: Azure Portal > Subscriptions
|
||||
Look for: "Subscription ID" column or click your subscription > Copy ID
|
||||
Format: 12345678-1234-1234-1234-123456789012
|
||||
|
||||
3. RESOURCE GROUP (Required)
|
||||
Location: Azure Portal > Cosmos DB Account > Top-right corner
|
||||
Look for: "Resource group" field
|
||||
Example: my-production-rg
|
||||
|
||||
4. ACCOUNT NAME (Required)
|
||||
Location: Either extract from endpoint URL or find in portal
|
||||
From URL: Take the part before ".documents.azure.com"
|
||||
From Portal: Account name appears in the breadcrumb and overview
|
||||
Example: my-cosmos-account
|
||||
|
||||
5. PRIVATE ENDPOINT IP (Optional, but recommended)
|
||||
Location: Azure Portal > Cosmos DB Account > Private Endpoint Connections
|
||||
Look for: "Private IP address" column (only if private endpoints exist)
|
||||
Format: 10.123.171.30 (will be 10.x.x.x or 172.16-31.x.x range)
|
||||
Skip this if: You are using public endpoint only
|
||||
|
||||
6. VPN SUBNET RANGE (Optional)
|
||||
Location: Ask your network team or VPN client settings
|
||||
Used to: Analyze if routing from your network to private endpoint is blocked
|
||||
Format: 10.0.0.0/24 (CIDR notation)
|
||||
Skip this if: You are not using a VPN
|
||||
|
||||
═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
"@
|
||||
}
|
||||
|
||||
function Read-InputsInteractively {
|
||||
Show-InputInstructions
|
||||
|
||||
Write-Host "Please provide the following information:" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Endpoint URL
|
||||
do {
|
||||
$endpoint = Read-Host "Endpoint URL (e.g., https://my-cosmos.documents.azure.com)"
|
||||
if ($endpoint -notmatch "^https://[a-z0-9-]+\.documents\.azure\.com") {
|
||||
Write-Host "Invalid format. Expected: https://<account-name>.documents.azure.com" -ForegroundColor Yellow
|
||||
}
|
||||
} while ($endpoint -notmatch "^https://[a-z0-9-]+\.documents\.azure\.com")
|
||||
|
||||
# Subscription ID
|
||||
do {
|
||||
$subId = Read-Host "Subscription ID (12345678-1234-1234-1234-123456789012)"
|
||||
if ($subId -notmatch "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") {
|
||||
Write-Host "Invalid format. Expected GUID format." -ForegroundColor Yellow
|
||||
}
|
||||
} while ($subId -notmatch "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
$rg = Read-Host "Resource Group name"
|
||||
$account = Read-Host "Account Name"
|
||||
$peIP = Read-Host "Private Endpoint IP (optional, press Enter to skip)"
|
||||
$vpnSubnet = Read-Host "VPN Subnet Range (optional, e.g., 10.0.0.0/24, press Enter to skip)"
|
||||
|
||||
return @{
|
||||
EndpointUrl = $endpoint
|
||||
SubscriptionId = [guid]$subId
|
||||
ResourceGroup = $rg
|
||||
AccountName = $account
|
||||
PrivateEndpointIP = if ($peIP) { $peIP } else { $null }
|
||||
VpnSubnetRange = if ($vpnSubnet) { $vpnSubnet } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DnsResolution {
|
||||
param([string]$Hostname)
|
||||
|
||||
$result = @{
|
||||
hostname = $Hostname
|
||||
succeeded = $false
|
||||
addresses = @()
|
||||
error = $null
|
||||
dnsServers = @()
|
||||
latencyMs = 0
|
||||
}
|
||||
|
||||
try {
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$addresses = [System.Net.Dns]::GetHostAddresses($Hostname)
|
||||
$stopwatch.Stop()
|
||||
|
||||
$result.succeeded = $true
|
||||
$result.addresses = @($addresses | ForEach-Object { $_.ToString() })
|
||||
$result.latencyMs = [int]$stopwatch.ElapsedMilliseconds
|
||||
|
||||
# Try to get DNS servers (Windows/Linux specific)
|
||||
if ($PSVersionTable.Platform -ne "Unix" -or $PSVersionTable.OS -like "*Linux*") {
|
||||
try {
|
||||
$dnsConfig = Get-DnsClientServerAddress -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($dnsConfig) {
|
||||
$result.dnsServers = @($dnsConfig.ServerAddresses)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch {
|
||||
$result.error = $_.Exception.Message
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Invoke-TcpConnectivityTest {
|
||||
param(
|
||||
[string]$Hostname,
|
||||
[int]$Port = 443,
|
||||
[int]$TimeoutMs = 5000
|
||||
)
|
||||
|
||||
$result = @{
|
||||
hostname = $Hostname
|
||||
port = $Port
|
||||
succeeded = $false
|
||||
error = $null
|
||||
latencyMs = 0
|
||||
sourceIp = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$tcpClient = New-Object System.Net.Sockets.TcpClient
|
||||
$task = $tcpClient.ConnectAsync($Hostname, $Port)
|
||||
$task.Wait($TimeoutMs)
|
||||
$stopwatch.Stop()
|
||||
|
||||
if ($task.IsCompleted) {
|
||||
$result.succeeded = $true
|
||||
$result.latencyMs = [int]$stopwatch.ElapsedMilliseconds
|
||||
|
||||
# Try to get source IP
|
||||
try {
|
||||
$endpoint = $tcpClient.Client.LocalEndPoint
|
||||
$result.sourceIp = $endpoint.Address.ToString()
|
||||
} catch { }
|
||||
} else {
|
||||
$result.error = "Connection timeout after ${TimeoutMs}ms"
|
||||
}
|
||||
|
||||
$tcpClient.Close()
|
||||
} catch {
|
||||
$result.error = $_.Exception.Message
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Invoke-HttpsProbe {
|
||||
param([string]$Url)
|
||||
|
||||
$result = @{
|
||||
url = $Url
|
||||
succeeded = $false
|
||||
statusCode = $null
|
||||
error = $null
|
||||
latencyMs = 0
|
||||
}
|
||||
|
||||
try {
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$response = Invoke-WebRequest -Uri $Url -Method Head -TimeoutSec 5 -ErrorAction Stop
|
||||
$stopwatch.Stop()
|
||||
|
||||
$result.succeeded = $true
|
||||
$result.statusCode = [int]$response.StatusCode
|
||||
$result.latencyMs = [int]$stopwatch.ElapsedMilliseconds
|
||||
} catch {
|
||||
$result.statusCode = [int]($_.Exception.Response.StatusCode)
|
||||
$result.error = $_.Exception.Message
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-PrivateNetworkIndicators {
|
||||
param(
|
||||
[string[]]$ResolvedAddresses,
|
||||
[string]$PrivateEndpointIP,
|
||||
[string]$VpnSubnetRange
|
||||
)
|
||||
|
||||
$result = @{
|
||||
isPrivateRange = $false
|
||||
indicators = @()
|
||||
matchesExpectedPrivateEndpoint = $false
|
||||
vpnRouteWarning = $null
|
||||
}
|
||||
|
||||
# Check if resolved IPs are private range
|
||||
foreach ($addr in $ResolvedAddresses) {
|
||||
if (IsPrivateIpAddress $addr) {
|
||||
$result.isPrivateRange = $true
|
||||
$result.indicators += "Resolved to RFC 1918 private IP range ($addr)"
|
||||
}
|
||||
}
|
||||
|
||||
# Check if matches expected private endpoint
|
||||
if ($PrivateEndpointIP -and $ResolvedAddresses -contains $PrivateEndpointIP) {
|
||||
$result.matchesExpectedPrivateEndpoint = $true
|
||||
$result.indicators += "Matches expected private endpoint IP ($PrivateEndpointIP)"
|
||||
} elseif ($PrivateEndpointIP -and $ResolvedAddresses.Count -gt 0) {
|
||||
$result.indicators += "WARNING: Resolved to $($ResolvedAddresses[0]) but expected private endpoint IP is $PrivateEndpointIP"
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function IsPrivateIpAddress {
|
||||
param([string]$IpAddress)
|
||||
|
||||
try {
|
||||
$ip = [System.Net.IPAddress]::Parse($IpAddress)
|
||||
# RFC 1918 ranges
|
||||
if ($ip.ToString() -match "^10\." -or $ip.ToString() -match "^172\.(1[6-9]|2[0-9]|3[01])\." -or $ip.ToString() -match "^192\.168\.") {
|
||||
return $true
|
||||
}
|
||||
# Loopback
|
||||
if ($ip.AddressFamily -eq "InterNetwork" -and $ip.GetAddressBytes()[0] -eq 127) {
|
||||
return $true
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-AzureCliContext {
|
||||
$result = @{
|
||||
installed = $false
|
||||
authenticated = $false
|
||||
currentUser = $null
|
||||
currentTenant = $null
|
||||
currentSubscription = $null
|
||||
error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$output = & az --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$result.installed = $true
|
||||
}
|
||||
} catch {
|
||||
$result.error = "Azure CLI not found. Skipping Azure context checks."
|
||||
return $result
|
||||
}
|
||||
|
||||
try {
|
||||
$account = & az account show 2>&1 | ConvertFrom-Json
|
||||
$result.authenticated = $true
|
||||
$result.currentUser = $account.user.name
|
||||
$result.currentTenant = $account.tenantId
|
||||
$result.currentSubscription = $account.id
|
||||
} catch {
|
||||
$result.error = "Not authenticated with Azure CLI. Run 'az login' to proceed with Azure checks."
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-AzureAccountNetworkConfig {
|
||||
param(
|
||||
[guid]$SubscriptionId,
|
||||
[string]$ResourceGroup,
|
||||
[string]$AccountName
|
||||
)
|
||||
|
||||
$result = @{
|
||||
checked = $false
|
||||
publicNetworkAccessRestricted = $null
|
||||
privateEndpoints = @()
|
||||
vnetRules = @()
|
||||
error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$scope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.DocumentDB/databaseAccounts/$AccountName"
|
||||
$account = & az cosmosdb show --resource-group $ResourceGroup --name $AccountName 2>&1 | ConvertFrom-Json
|
||||
|
||||
if ($account) {
|
||||
$result.checked = $true
|
||||
$result.publicNetworkAccessRestricted = $account.properties.publicNetworkAccess -eq "Disabled"
|
||||
|
||||
# Get private endpoints
|
||||
$peConnections = & az cosmosdb private-endpoint-connection list --resource-group $ResourceGroup --name $AccountName 2>&1 | ConvertFrom-Json
|
||||
if ($peConnections) {
|
||||
$result.privateEndpoints = @($peConnections | Select-Object -Property id, @{n='state';e={$_.properties.privateLinkServiceConnectionState.status}})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result.error = $_.Exception.Message
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-RbacAssessment {
|
||||
param(
|
||||
[guid]$SubscriptionId,
|
||||
[string]$ResourceGroup,
|
||||
[string]$AccountName
|
||||
)
|
||||
|
||||
$result = @{
|
||||
checked = $false
|
||||
canReadAccount = $false
|
||||
canManageAccount = $false
|
||||
canExecuteDataPlaneOps = $false
|
||||
roleAssignments = @()
|
||||
classification = "unknown"
|
||||
error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$scope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.DocumentDB/databaseAccounts/$AccountName"
|
||||
|
||||
# Try to read account (implies Reader or higher)
|
||||
$account = & az cosmosdb show --resource-group $ResourceGroup --name $AccountName 2>&1 | ConvertFrom-Json
|
||||
if ($account) {
|
||||
$result.checked = $true
|
||||
$result.canReadAccount = $true
|
||||
|
||||
# Check role assignments
|
||||
$roles = & az role assignment list --scope $scope 2>&1 | ConvertFrom-Json
|
||||
if ($roles) {
|
||||
$result.roleAssignments = @($roles | Select-Object -Property roleDefinitionName, principalName)
|
||||
|
||||
# Classify permissions
|
||||
$roleNames = $roles | Select-Object -ExpandProperty roleDefinitionName
|
||||
if ($roleNames -contains "Contributor" -or $roleNames -contains "Owner") {
|
||||
$result.canManageAccount = $true
|
||||
$result.canExecuteDataPlaneOps = $true
|
||||
$result.classification = "sufficient"
|
||||
} elseif ($roleNames -contains "Cosmos DB Operator" -or $roleNames -contains "Cosmos DB Account Reader") {
|
||||
$result.canExecuteDataPlaneOps = $true
|
||||
$result.classification = "partial"
|
||||
} else {
|
||||
$result.classification = "partial"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result.error = $_.Exception.Message
|
||||
$result.classification = "insufficient"
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Invoke-Classification {
|
||||
param(
|
||||
[hashtable]$DnsResult,
|
||||
[hashtable]$TcpResult,
|
||||
[hashtable]$PrivateNetworkIndicators,
|
||||
[hashtable]$AzureNetworkConfig
|
||||
)
|
||||
|
||||
$classification = @{
|
||||
status = "unknown"
|
||||
code = "unknown"
|
||||
summary = "Unable to classify"
|
||||
rootCause = $null
|
||||
recommendedActions = @()
|
||||
}
|
||||
|
||||
# DNS failure
|
||||
if (-not $DnsResult.succeeded) {
|
||||
$classification.status = "failure"
|
||||
$classification.code = "dns_resolution_failed"
|
||||
$classification.summary = "DNS resolution failed. The Cosmos DB endpoint hostname cannot be resolved."
|
||||
$classification.rootCause = "DNS configuration, VPN/proxy DNS redirect, or network connectivity issue"
|
||||
$classification.recommendedActions = @(
|
||||
"1. Check if you are connected to corporate VPN or proxy that intercepts DNS",
|
||||
"2. Manually run: nslookup $($DnsResult.hostname)",
|
||||
"3. If nslookup fails, check with your network team or ISP",
|
||||
"4. Try pinging the endpoint or using nslookup with alternate DNS: nslookup $($DnsResult.hostname) 8.8.8.8"
|
||||
)
|
||||
return $classification
|
||||
}
|
||||
|
||||
# DNS succeeded but TCP failed
|
||||
if ($DnsResult.succeeded -and -not $TcpResult.succeeded) {
|
||||
$classification.status = "failure"
|
||||
$classification.code = "tcp_connectivity_blocked"
|
||||
$classification.summary = "DNS resolution succeeded but TCP 443 connection failed. Network path is blocked."
|
||||
|
||||
if ($PrivateNetworkIndicators.isPrivateRange) {
|
||||
$classification.rootCause = "Private endpoint configured but network path blocked (VPN routing, firewall/NVA, NSG, UDR, or peering issue)"
|
||||
$classification.recommendedActions = @(
|
||||
"1. Verify VPN connectivity and that your client subnet can route to the private endpoint subnet",
|
||||
"2. Ask your network team to verify routing between $([System.Net.Dns]::GetHostName()) and private endpoint $($DnsResult.addresses[0])",
|
||||
"3. Check Azure network security groups (NSGs) rules for port 443 inbound",
|
||||
"4. Verify Azure Virtual Network peering and User Defined Routes (UDRs)",
|
||||
"5. Check if corporate firewall/NVA is blocking the connection",
|
||||
"6. Manually run: Test-NetConnection -ComputerName $($DnsResult.hostname) -Port 443"
|
||||
)
|
||||
} else {
|
||||
$classification.rootCause = "Public endpoint network path blocked (firewall, proxy, ISP, or regional restriction)"
|
||||
$classification.recommendedActions = @(
|
||||
"1. Check if corporate firewall is blocking outbound port 443",
|
||||
"2. If behind proxy, verify proxy settings allow HTTPS to documents.azure.com",
|
||||
"3. Manually run: Test-NetConnection -ComputerName $($DnsResult.hostname) -Port 443",
|
||||
"4. Try connecting from a different network to isolate the issue"
|
||||
)
|
||||
}
|
||||
return $classification
|
||||
}
|
||||
|
||||
# Both succeeded
|
||||
if ($DnsResult.succeeded -and $TcpResult.succeeded) {
|
||||
$classification.status = "success"
|
||||
$classification.code = "network_connectivity_healthy"
|
||||
$classification.summary = "Network connectivity is healthy. DNS resolves and TCP 443 is reachable."
|
||||
$classification.rootCause = $null
|
||||
$classification.recommendedActions = @(
|
||||
"✓ Local network connectivity is working",
|
||||
"If Cosmos DB operations still fail, check:",
|
||||
" - RBAC/authentication permissions",
|
||||
" - Account firewall IP rules (if enabled)",
|
||||
" - Data plane token expiry",
|
||||
" - Application-level issues (connection strings, SDK versions)"
|
||||
)
|
||||
return $classification
|
||||
}
|
||||
|
||||
return $classification
|
||||
}
|
||||
|
||||
function Redact-Sensitive {
|
||||
param([object]$Object)
|
||||
|
||||
if (-not $Redact) { return $Object }
|
||||
|
||||
$json = $Object | ConvertTo-Json -Depth 10
|
||||
$json = $json -replace [regex]::Escape($SubscriptionId.ToString()), "REDACTED-SUBSCRIPTION-ID"
|
||||
|
||||
# Redact tenant IDs (GUIDs in certain fields)
|
||||
$json = $json -replace '"currentTenant"\s*:\s*"[^"]*"', '"currentTenant": "REDACTED-TENANT-ID"'
|
||||
|
||||
# Redact user names
|
||||
$json = $json -replace '"currentUser"\s*:\s*"[^"]*"', '"currentUser": "REDACTED-USER-NAME"'
|
||||
$json = $json -replace '"principalName"\s*:\s*"[^"]*"', '"principalName": "REDACTED-PRINCIPAL-NAME"'
|
||||
|
||||
return $json | ConvertFrom-Json
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Execution
|
||||
# ============================================================================
|
||||
|
||||
try {
|
||||
# Validate and collect inputs
|
||||
if ($Interactive -and -not $EndpointUrl) {
|
||||
$inputs = Read-InputsInteractively
|
||||
$EndpointUrl = $inputs.EndpointUrl
|
||||
$SubscriptionId = $inputs.SubscriptionId
|
||||
$ResourceGroup = $inputs.ResourceGroup
|
||||
$AccountName = $inputs.AccountName
|
||||
$PrivateEndpointIP = $inputs.PrivateEndpointIP
|
||||
$VpnSubnetRange = $inputs.VpnSubnetRange
|
||||
} elseif (-not $EndpointUrl) {
|
||||
Write-Host "No endpoint URL provided. Use -Interactive flag or provide parameters." -ForegroundColor Red
|
||||
Show-InputInstructions
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Extract hostname from URL
|
||||
$uri = [System.Uri]$EndpointUrl
|
||||
$hostname = $uri.Host
|
||||
|
||||
Write-Host "Collecting diagnostics for: $hostname" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Run diagnostics
|
||||
Write-Host "[1/5] DNS Resolution..." -ForegroundColor Cyan
|
||||
$dnsResult = Invoke-DnsResolution -Hostname $hostname
|
||||
|
||||
Write-Host "[2/5] TCP Connectivity (port 443)..." -ForegroundColor Cyan
|
||||
$tcpResult = Invoke-TcpConnectivityTest -Hostname $hostname -Port 443 -TimeoutMs $TcpConnectTimeoutMs
|
||||
|
||||
Write-Host "[3/5] HTTPS Probe..." -ForegroundColor Cyan
|
||||
$httpsResult = Invoke-HttpsProbe -Url $EndpointUrl
|
||||
|
||||
Write-Host "[4/5] Private Network Analysis..." -ForegroundColor Cyan
|
||||
$privateNetIndicators = Get-PrivateNetworkIndicators -ResolvedAddresses $dnsResult.addresses -PrivateEndpointIP $PrivateEndpointIP -VpnSubnetRange $VpnSubnetRange
|
||||
|
||||
Write-Host "[5/5] Azure Configuration & RBAC..." -ForegroundColor Cyan
|
||||
$cliContext = Get-AzureCliContext
|
||||
$networkConfig = @{ checked = $false; error = "Skipped" }
|
||||
$rbacAssessment = @{ checked = $false; classification = "unknown"; error = "Skipped" }
|
||||
|
||||
if ($cliContext.authenticated -and $SubscriptionId -and $ResourceGroup -and $AccountName) {
|
||||
$networkConfig = Get-AzureAccountNetworkConfig -SubscriptionId $SubscriptionId -ResourceGroup $ResourceGroup -AccountName $AccountName
|
||||
$rbacAssessment = Get-RbacAssessment -SubscriptionId $SubscriptionId -ResourceGroup $ResourceGroup -AccountName $AccountName
|
||||
} elseif (-not $cliContext.authenticated) {
|
||||
Write-Host " ⚠ Azure CLI not authenticated. Skipping Azure checks. Run 'az login' to enable." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Generating classification..." -ForegroundColor Cyan
|
||||
$classification = Invoke-Classification -DnsResult $dnsResult -TcpResult $tcpResult -PrivateNetworkIndicators $privateNetIndicators -AzureNetworkConfig $networkConfig
|
||||
|
||||
# Build final report
|
||||
$report = @{
|
||||
version = $ScriptVersion
|
||||
timestamp = $DiagnosticTimestamp
|
||||
target = @{
|
||||
endpointUrl = if ($Redact) { "REDACTED" } else { $EndpointUrl }
|
||||
hostname = $hostname
|
||||
subscriptionId = if ($Redact -and $SubscriptionId) { "REDACTED" } else { $SubscriptionId.ToString() }
|
||||
resourceGroup = if ($Redact -and $ResourceGroup) { "REDACTED" } else { $ResourceGroup }
|
||||
accountName = if ($Redact -and $AccountName) { "REDACTED" } else { $AccountName }
|
||||
}
|
||||
execution = @{
|
||||
hostname = [System.Net.Dns]::GetHostName()
|
||||
platform = $PSVersionTable.OS
|
||||
powershellVersion = $PSVersionTable.PSVersion.ToString()
|
||||
}
|
||||
diagnostics = @{
|
||||
dns = $dnsResult
|
||||
tcp = $tcpResult
|
||||
https = $httpsResult
|
||||
privateNetwork = $privateNetIndicators
|
||||
azureNetworkConfig = $networkConfig
|
||||
rbac = $rbacAssessment
|
||||
azureCli = $cliContext
|
||||
}
|
||||
classification = $classification
|
||||
}
|
||||
|
||||
# Redact if requested
|
||||
if ($Redact) {
|
||||
$report = Redact-Sensitive -Object $report
|
||||
}
|
||||
|
||||
# Output JSON report
|
||||
$jsonReport = $report | ConvertTo-Json -Depth 10
|
||||
|
||||
# Save to file
|
||||
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$outputFile = "cosmos-diagnostic-$timestamp.json"
|
||||
$jsonReport | Out-File -FilePath $outputFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "═════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
|
||||
Write-Host "DIAGNOSTIC COMPLETE" -ForegroundColor Green
|
||||
Write-Host "═════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Summary:" -ForegroundColor Cyan
|
||||
Write-Host " DNS Resolution: $(if ($dnsResult.succeeded) { '✓ PASS' } else { '✗ FAIL' })"
|
||||
Write-Host " TCP Connectivity: $(if ($tcpResult.succeeded) { '✓ PASS' } else { '✗ FAIL' })"
|
||||
Write-Host " Private Network: $(if ($privateNetIndicators.isPrivateRange) { 'Detected (Private Endpoint)' } else { 'Not Detected (Public Endpoint)' })"
|
||||
Write-Host " Classification: $($classification.status.ToUpper()) - $($classification.code)"
|
||||
Write-Host ""
|
||||
Write-Host "Full report saved to: $outputFile" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Summary:" -ForegroundColor Yellow
|
||||
Write-Host $classification.summary
|
||||
Write-Host ""
|
||||
if ($classification.recommendedActions.Count -gt 0) {
|
||||
Write-Host "Recommended Actions:" -ForegroundColor Yellow
|
||||
$classification.recommendedActions | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Output JSON to console for easy copy/paste
|
||||
Write-Host "Full JSON Report:" -ForegroundColor Cyan
|
||||
Write-Host "─────────────────────────────────────────────────────────────────────────────"
|
||||
Write-Host $jsonReport
|
||||
|
||||
} catch {
|
||||
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user