Private/Pac.ps1
|
#Requires -Version 5.1 # Proxy auto-config (PAC) inspection (docs/17 section 4.3, ADR-0038): network.proxy.pac. # # * Which setup scripts are in force: the device-wide one when the per-machine policy is on, else each signed-in user's # and NetworkService's (DoSvc's account). Read from the registry only (Get-DDPacSourcesRaw); no hive is ever loaded. # * Each PAC is downloaded once, directly, as WinHTTP would (http and https only, 1 MB at most), and searched for the # Delivery Optimization endpoints of Data/endpoints.json. WinHTTP then evaluates it for one DO cloud and one DO content # URL, so an admin can check the PAC when DO has connectivity issues. # * Privacy: nothing about the PAC leaves the device but a tenant-salted fingerprint (so the console can count versions # across devices), which scopes use it, a count of signed-in user profiles, which DO endpoint groups it names and what # it answers for them (direct or proxy). Never its URL, host, text or the proxies it names; never a user or SID. Set-StrictMode -Version Latest # Endpoint services (Data/endpoints.json) a PAC is searched for, and the URL WinHTTP evaluates for each route. $script:DDPacServices = @('do-cloud', 'do-content', 'wu-content') $script:DDPacRouteUrls = [ordered]@{ doCloud = 'https://geo.prod.do.dsp.mp.microsoft.com/geo'; doContent = 'http://tlu.dl.delivery.mp.microsoft.com/filestreamingservice/files/' } $script:DDPacMaxItems = 4 $script:DDPacMaxBytes = 1048576 # WinHTTP rejects a larger PAC function ConvertFrom-DDConnectionSettings { <# .SYNOPSIS WinINET's DefaultConnectionSettings (hex): flags at byte 8 (0x04 setup script, 0x08 automatically detect), then three length-prefixed strings: proxy server, bypass list, setup script URL. $null when it cannot be parsed. #> [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string] $Hex) if ([string]::IsNullOrEmpty($Hex) -or $Hex.Length -lt 24 -or ($Hex.Length % 2) -ne 0 -or $Hex -notmatch '^[0-9a-fA-F]+$') { return $null } $b = New-Object byte[] ($Hex.Length / 2) for ($i = 0; $i -lt $b.Length; $i++) { $b[$i] = [Convert]::ToByte($Hex.Substring($i * 2, 2), 16) } $pos = 12; $strings = @() for ($n = 0; $n -lt 3; $n++) { if ($pos + 4 -gt $b.Length) { break } $len = [BitConverter]::ToInt32($b, $pos); $pos += 4 if ($len -lt 0 -or $pos + $len -gt $b.Length) { break } $strings += [System.Text.Encoding]::ASCII.GetString($b, $pos, $len) $pos += $len } [pscustomobject]@{ Flags = [BitConverter]::ToInt32($b, 8); AutoConfigUrl = $(if ($strings.Count -ge 3 -and $strings[2]) { $strings[2] } else { $null }) } } function Get-DDPacSources { <# .SYNOPSIS The setup scripts in force, from Get-DDPacSourcesRaw: @{ Scope; Url }. With the per-machine policy on, only the device's (users cannot set their own); otherwise each signed-in user's and NetworkService's. An AutoConfigURL value wins; else the connection blob's URL, which counts only with its setup-script flag set. #> [CmdletBinding()] param($Raw) if ($null -eq $Raw) { return } $out = New-Object 'System.Collections.Generic.List[object]' foreach ($e in @($Raw.Entries)) { if ([bool]$Raw.PerMachine -ne ($e.Scope -eq 'device')) { continue } $url = [string]$e.AutoConfigUrl if (-not $url.Trim()) { $c = ConvertFrom-DDConnectionSettings -Hex $e.Connections if ($null -ne $c -and ($c.Flags -band 4) -and $c.AutoConfigUrl) { $url = $c.AutoConfigUrl } } if ($url.Trim()) { $out.Add([pscustomobject]@{ Scope = [string]$e.Scope; Url = $url.Trim() }) } } $out.ToArray() } function Get-DDPacDomainNames { # The names a PAC would test for a manifest pattern: its fixed part ('*.do.dsp.mp.microsoft.com' -> do.dsp.mp.microsoft.com) # and the parents of that down to three labels (dsp.mp.microsoft.com, mp.microsoft.com), never microsoft.com itself. [CmdletBinding()] param([Parameter(Mandatory)][string] $Pattern) $labels = @($Pattern.Trim().ToLowerInvariant().TrimEnd('.').Split('.')) $i = 0 while ($i -lt $labels.Count -and $labels[$i].Contains('*')) { $i++ } $fixed = @($labels | Select-Object -Skip $i) for ($k = 0; $k -lt $fixed.Count; $k++) { if ($k -eq 0 -or ($fixed.Count - $k) -ge 3) { ($fixed | Select-Object -Skip $k) -join '.' } } } function Get-DDPacDoReferences { <# .SYNOPSIS The Delivery Optimization endpoint services (Data/endpoints.json) a PAC's text names, sorted: do-cloud, do-content, wu-content. A name counts as a whole DNS name (the character before and after is not a letter, digit or hyphen). #> [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string] $Text, [Parameter(Mandatory)] $Manifest) if ([string]::IsNullOrEmpty($Text)) { return } $found = New-Object 'System.Collections.Generic.SortedSet[string]' foreach ($e in @($Manifest['endpoints'])) { $svc = [string]$e['service'] if ($script:DDPacServices -notcontains $svc -or $found.Contains($svc)) { continue } $names = @(Get-DDPacDomainNames -Pattern ([string]$e['pattern'])) for ($j = 0; $j -lt $names.Count; $j++) { # The pattern's own name counts with any subdomain (its wildcard); a parent only as a name in its own right, so # "mp.microsoft.com" inside "do.dsp.mp.microsoft.com" or "foo.mp.microsoft.com" is not a rule for every DO host. $before = $(if ($j -eq 0) { '(?<![a-z0-9-])' } else { '(?<![a-z0-9-])(?<![a-z0-9-]\.)' }) if ([regex]::IsMatch($Text, '(?i)' + $before + [regex]::Escape($names[$j]) + '(?![a-z0-9-])')) { [void]$found.Add($svc); break } } } @($found) } function ConvertTo-DDPacFetchState { # The outcome of one PAC download (Invoke-DDHttpFetch): ok | too-large | timeout | failed. [CmdletBinding()] param($Outcome) if ($null -eq $Outcome) { return 'failed' } if ($Outcome.Body -and $Outcome.BodyTruncated) { return 'too-large' } if ($Outcome.Body) { return 'ok' } if ($Outcome.TimedOut) { return 'timeout' } 'failed' } function Get-DDPacFacts { <# .SYNOPSIS network.proxy.pac (packages/schema PacFacts): whether a PAC is in force on this device, whether it names Delivery Optimization endpoints, how many different PACs are in force, and what each answers for DO. $null when the registry could not be read. -Skip (-SkipConnectivityChecks) reads the registry only: nothing is downloaded or run. #> [CmdletBinding()] param([Parameter(Mandatory)] $Context, [switch] $Skip, [int] $BudgetMs = 15000) $raw = Invoke-DDStep -Context $Context -Field 'network.proxy.pac' -Script { Get-DDPacSourcesRaw } if ($null -eq $raw) { return $null } if ([int]$raw.UsersUnreadable -gt 0) { Add-DDNotCollected -Context $Context -Field 'network.proxy.pac.users' -Reason 'insufficient-rights' -Detail "$([int]$raw.UsersUnreadable) signed-in user profile(s) could not be read; the collector reads them when run as SYSTEM or elevated" } $sources = @(Get-DDPacSources -Raw $raw) $pac = [ordered]@{ configured = ($sources.Count -gt 0); versions = 0; referencesDo = $null; userProfiles = [int]$raw.UsersRead; items = @() } if ($sources.Count -eq 0) { return $pac } # One entry per PAC URL, with the scopes that use it and how many signed-in users point at it. $byUrl = [ordered]@{} foreach ($s in $sources) { $k = $s.Url.ToLowerInvariant() if (-not $byUrl.Contains($k)) { $byUrl[$k] = [pscustomobject]@{ Url = $s.Url; Scopes = (New-Object 'System.Collections.Generic.SortedSet[string]'); Users = 0 } } [void]$byUrl[$k].Scopes.Add($s.Scope) if ($s.Scope -eq 'user') { $byUrl[$k].Users++ } } if ($byUrl.Count -gt $script:DDPacMaxItems) { Add-DDNotCollected -Context $Context -Field 'network.proxy.pac.items' -Reason 'error' -Detail "$($byUrl.Count) different PAC URLs are in force; the first $($script:DDPacMaxItems) are reported" } $manifest = Get-DDEndpointManifest $sw = [System.Diagnostics.Stopwatch]::StartNew() $items = [ordered]@{} foreach ($g in @($byUrl.Values | Select-Object -First $script:DDPacMaxItems)) { $u = $null $web = [System.Uri]::TryCreate($g.Url, [System.UriKind]::Absolute, [ref]$u) -and $u.Scheme -in 'http', 'https' $fetch = $(if (-not $web) { 'unsupported' } else { 'skipped' }); $text = $null $routes = [ordered]@{ doCloud = $null; doContent = $null } if ($web -and -not $Skip -and $sw.ElapsedMilliseconds -lt $BudgetMs) { # Errors are recorded without their message: it can carry the PAC's URL. try { $f = Invoke-DDHttpFetch -Uri $g.Url -TimeoutMs 5000 -MaxBody $script:DDPacMaxBytes -ReadBodyFor @('') $fetch = ConvertTo-DDPacFetchState -Outcome $f if ($f.Body) { $text = [System.Text.Encoding]::UTF8.GetString([byte[]]$f.Body) } } catch { $fetch = 'failed'; Add-DDNotCollected -Context $Context -Field 'network.proxy.pac' -Reason (Get-DDNotCollectedReason -ErrorRecord $_) -Detail 'the PAC could not be downloaded' } foreach ($r in @($script:DDPacRouteUrls.Keys)) { if ($sw.ElapsedMilliseconds -ge $BudgetMs) { break } try { $d = Resolve-DDPacProxy -Uri $script:DDPacRouteUrls[$r] -PacUrl $g.Url -TimeoutMs 4000 $routes[$r] = $(if ($null -eq $d -or $d.Error) { 'error' } elseif ($d.Proxy) { 'proxy' } else { 'direct' }) } catch { $routes[$r] = 'error' } } } $basis = $(if ($fetch -eq 'ok') { 'content' } else { 'url' }) $id = 'pac:' + $(if ($basis -eq 'content') { Get-DDShortHash -Value $text -Salt $Context.TenantSalt -Domain 'pac' } else { Get-DDShortHash -Value $g.Url.ToLowerInvariant() -Salt $Context.TenantSalt -Domain 'pac-url' }) if ($items.Contains($id)) { # Two URLs serving the same script are one version. $it = $items[$id] $it['scopes'] = @(@($it['scopes']) + @($g.Scopes) | Sort-Object -Unique) $it['userProfiles'] += $g.Users continue } $items[$id] = [ordered]@{ id = $id basis = $basis scopes = @($g.Scopes) userProfiles = [int]$g.Users fetch = $fetch doReferences = @(Get-DDPacDoReferences -Text $text -Manifest $manifest) routes = $routes } $text = $null } $list = @($items.Values) $read = @($list | Where-Object { $_['fetch'] -in 'ok', 'too-large' }) $pac['versions'] = $list.Count $pac['referencesDo'] = $(if ($read.Count -gt 0) { [bool](@($read | Where-Object { @($_['doReferences']).Count -gt 0 }).Count) } else { $null }) $pac['items'] = $list $pac } function Get-DDPacCheck { <# .SYNOPSIS The proxy-pac connectivity check (DO-NET-025) from network.proxy.pac and the checks already in the matrix: whether a PAC is in force and names DO endpoints, whether more than one is in force, and, when DO's own endpoints are failing on this device, a pointer to the PAC with what it answers for DO. #> [CmdletBinding()] param($Network, [AllowEmptyCollection()] $Checks = @()) $px = $(if ($Network -is [System.Collections.IDictionary]) { $Network['proxy'] } else { $null }) $pac = $(if ($px -is [System.Collections.IDictionary] -and $px.Contains('pac')) { $px['pac'] } else { $null }) if ($null -eq $pac) { return New-DDCheck -CheckId 'proxy-pac' -Protocol local -Result 'not-collected' -Detail 'PAC configuration could not be read' } if (-not $pac['configured']) { return New-DDCheck -CheckId 'proxy-pac' -Protocol local -Result 'not-applicable' -Detail 'No proxy auto-config (PAC) script in force' } $items = @($pac['items']) $scopeWords = @{ device = 'device-wide'; user = 'signed-in users'; service = 'NetworkService (DoSvc)' } $scopes = @($items | ForEach-Object { @($_['scopes']) } | Sort-Object -Unique) $names = @($items | ForEach-Object { @($_['doReferences']) } | Sort-Object -Unique) $routeOf = { param($k) @($items | ForEach-Object { $_['routes'][$k] } | Where-Object { $_ } | Sort-Object -Unique) } $cloud = @(& $routeOf 'doCloud'); $content = @(& $routeOf 'doContent') $cloudProxy = $cloud -contains 'proxy' $unread = [bool](@($items | Where-Object { $_['fetch'] -in 'failed', 'timeout' }).Count) $tooLarge = [bool](@($items | Where-Object { $_['fetch'] -eq 'too-large' }).Count) $fileUrl = [bool](@($items | Where-Object { $_['fetch'] -eq 'unsupported' }).Count) $skipped = $items.Count -gt 0 -and -not (@($items | Where-Object { $_['fetch'] -ne 'skipped' }).Count) $doIssue = [bool](@($Checks | Where-Object { $_['checkId'] -in 'do-cloud-dns', 'do-cloud-tls', 'do-cloud-inspect', 'proxy-tls-inspection', 'do-content-cdn', 'do-range-support' -and $_['result'] -eq 'fail' }).Count) $versions = [int]$pac['versions'] $summary = 'PAC in force ({0}); {1}; {2} version{3}' -f (($scopes | ForEach-Object { $scopeWords[$_] }) -join ', '), $(if ($pac['referencesDo'] -eq $true) { "names DO endpoints ($($names -join ', '))" } elseif ($pac['referencesDo'] -eq $false) { 'names no DO endpoints' } elseif ($skipped) { 'not downloaded (connectivity checks skipped)' } else { 'could not be read' }), $versions, $(if ($versions -eq 1) { '' } else { 's' }) $problems = New-Object 'System.Collections.Generic.List[string]' if ($fileUrl) { $problems.Add('a file:// PAC, which WinHTTP and so Delivery Optimization cannot use') } if ($versions -gt 1) { $problems.Add('different PACs apply to the device, signed-in users or NetworkService, so downloads can be routed differently depending on who is signed in') } if ($unread) { $problems.Add('the PAC could not be downloaded') } if ($tooLarge) { $problems.Add('the PAC is larger than 1 MB, which WinHTTP rejects') } if ($cloudProxy) { $problems.Add('it sends the DO cloud service (*.do.dsp.mp.microsoft.com) through a proxy, where Microsoft recommends direct access') } if ($doIssue -and $problems.Count -eq 0 -and ($pac['referencesDo'] -eq $true -or $content -contains 'proxy')) { $problems.Add('it names or proxies DO endpoints that are failing on this device') } $result = $(if ($doIssue -and $cloudProxy) { 'fail' } elseif ($problems.Count -gt 0) { 'warn' } else { 'pass' }) $detail = $summary if ($problems.Count -gt 0) { $detail += ': ' + ($problems -join '; ') } if ($doIssue -and $problems.Count -gt 0) { $detail += '. DO endpoint checks are failing on this device: check the PAC' } $detail += ' (DO-NET-025)' $evidence = 'versions={0} scopes={1} names={2} doCloud={3} doContent={4}' -f $versions, ($scopes -join ','), $(if ($names.Count) { $names -join ',' } else { 'none' }), $(if ($cloud.Count) { $cloud -join ',' } else { 'not-evaluated' }), $(if ($content.Count) { $content -join ',' } else { 'not-evaluated' }) New-DDCheck -CheckId 'proxy-pac' -Protocol local -Result $result -Detail $detail -Evidence $evidence } |