#Requires -Version 5.1 # net.ps1 - "connected to Wi-Fi but nothing loads" diagnosis for Windows # # Purpose : You are associated with a Wi-Fi network, other people on that same # network are fine, and this laptop works on other networks. Walk the # stack from the adapter upwards and name the FIRST layer that # breaks, then print the command that fixes that specific layer. # Usage : irm https://hub.skillnav.dev/net.ps1 | iex # $env:HUB_FIX = '1'; irm https://hub.skillnav.dev/net.ps1 | iex # Idempotent: yes. The default run is strictly read-only. HUB_FIX performs only # two actions - flush the DNS cache, and renew the lease on the ONE # adapter this script selected, and only when that adapter is # confirmed to be on DHCP. Both land on the same state no matter how # often they run. (The macOS sibling cannot automate the renew at # all: its only command for it de-configures existing IPv4 services # first. Windows can target a single adapter, so it does.) # Dry run : $env:HUB_DRY_RUN = '1' prints what the fix would do instead of # doing it. The read-only probing still runs; it has nothing to hide. # Local test: $env:HUB_BASE overrides the base URL used in printed links. # # `irm | iex` cannot forward arguments, so the fix is opt-in through HUB_FIX # rather than a -Fix switch, matching how HUB_DRY_RUN already works here. # # This script never rewrites network settings, never deletes a Wi-Fi profile, # never touches the hosts file or the proxy configuration, and never edits the # registry. Every action that would overwrite something you configured is # printed for you to run yourself. # Contains no secrets and no internal hostnames; safe to serve publicly. # # Note: the whole script is wrapped in a function so that `irm | iex` cannot be # terminated by a stray `exit` - early returns leave your session alive. # NOTE: $ErrorActionPreference is deliberately NOT set here. `irm | iex` runs # this file in the CALLER's session, so a top-level assignment would leave the # user's shell permanently set to 'Stop' long after the script finished, making # unrelated later commands terminate on errors they used to survive. It is set # inside Invoke-HubNetCheck instead, where PowerShell's scoping reverts it # automatically when the function returns. # --- probe targets --------------------------------------------------------- # Two ping targets on purpose: one anycast address being dropped upstream # should not produce a false "no internet at all" verdict. $HubPingA = '223.5.5.5' # AliDNS, reachable from inside mainland China $HubPingB = '1.1.1.1' # Cloudflare, reachable from most other places $HubPublicDns = '223.5.5.5' # used for a lookup that bypasses system DNS $HubProbeHost = 'www.apple.com' $HubProbeUrl = 'http://cp.cloudflare.com/generate_204' $HubProbeTls = 'https://cp.cloudflare.com/generate_204' function Write-HubLog { param([Parameter(Mandatory = $true)][string] $Message) Write-Host "==> $Message" } function Write-HubOk { param([Parameter(Mandatory = $true)][string] $Message) Write-Host "ok $Message" } function Write-HubNote { # AllowEmptyString is load-bearing: the verdict text uses `Write-HubNote ''` # for blank lines, and a Mandatory [string] rejects '' outright. Without it # the script dies with a binding error at the exact moment it would have # printed the diagnosis. param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message) Write-Host " $Message" } function Write-HubDry { param([Parameter(Mandatory = $true)][string] $Message) Write-Host "DRY $Message" } # --- result table ---------------------------------------------------------- # FIRST_FAIL remembers the lowest layer that failed, which is the only thing # the verdict needs: everything above a broken layer is fallout, not a second # independent problem. $script:HubRows = New-Object System.Collections.ArrayList $script:HubFails = 0 $script:HubFirstFail = '' function Add-HubRow { param( [Parameter(Mandatory = $true)][ValidateSet('PASS', 'FAIL', 'SKIP')][string] $Status, [Parameter(Mandatory = $true)][string] $Layer, [Parameter(Mandatory = $true)][string] $Detail ) [void] $script:HubRows.Add([pscustomobject]@{ Status = $Status Layer = $Layer Detail = $Detail }) if ($Status -eq 'FAIL') { $script:HubFails = $script:HubFails + 1 if (-not $script:HubFirstFail) { $script:HubFirstFail = $Layer } } } # --- helpers --------------------------------------------------------------- # Test-HubPing - .NET rather than ping.exe or Test-Connection. # # ping.exe has to be judged by parsing its output, and that parsing is a trap: # exit code alone is not enough (some builds exit 0 for "destination host # unreachable"), the obvious discriminator - TTL - is absent from IPv6 replies # entirely, so requiring it marks every healthy IPv6 host as dead, and the rest # of the text is translated on a localised Windows. Test-Connection is no better # because its parameters and return shape drifted between PowerShell 5.1 and 7. # # IPStatus is a typed enum. No parsing, no localisation, same answer for IPv4 # and IPv6, identical on 5.1 and 7. function Test-HubPing { param( [string] $Target, [int] $Count = 2, [int] $TimeoutMs = 1500 ) if ([string]::IsNullOrWhiteSpace($Target)) { return $false } $ping = $null try { $ping = New-Object System.Net.NetworkInformation.Ping for ($i = 0; $i -lt $Count; $i++) { $reply = $ping.Send($Target, $TimeoutMs) if ($reply -and $reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) { return $true } } return $false } catch { return $false } finally { if ($ping) { $ping.Dispose() } } } # Test-HubAdapterUp - numeric first, the display string only as a fallback. # # $adapter.Status is a presentation value and there are credible reports of it # coming back translated on non-English Windows. Comparing it to the literal # 'Up' would then be false for a perfectly healthy adapter, and since this is # the bottom layer the whole diagnosis would collapse into "your Wi-Fi radio is # off" on exactly the zh-CN machines this script is most likely to meet. # InterfaceOperationalStatus is a uint32 from the CIM class (1 = IfOperStatusUp) # and cannot be translated, so prefer it and keep the string as a last resort. function Test-HubAdapterUp { param($Adapter) if (-not $Adapter) { return $false } try { if ($null -ne $Adapter.InterfaceOperationalStatus) { return ([int] $Adapter.InterfaceOperationalStatus -eq 1) } } catch { } return ($Adapter.Status -eq 'Up') } function Test-HubAdmin { try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } # Get-HubWifiAdapter - never hardcode "Wi-Fi": the adapter is renamed often # enough, and a dock or USB ethernet adapter can take the first slot. function Get-HubWifiAdapter { $candidates = @() try { $candidates = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.PhysicalMediaType -match '802\.11' -or $_.InterfaceDescription -match 'Wi-?Fi|Wireless|802\.11' }) } catch { } if ($candidates.Count -eq 0) { try { $candidates = @(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'Wi-?Fi|WLAN|无线' }) } catch { } } $up = $candidates | Where-Object { Test-HubAdapterUp $_ } | Select-Object -First 1 if ($up) { return $up } return ($candidates | Select-Object -First 1) } # --- the diagnosis --------------------------------------------------------- function Invoke-HubNetCheck { [CmdletBinding()] param() # Function-scoped on purpose: this shadows the caller's value for the # duration of the call and PowerShell restores theirs on return, so running # this through `irm | iex` does not leave their session altered. $ErrorActionPreference = 'Stop' # The `#Requires -Version 5.1` at the top of this file is NOT enforced when # the script is delivered through `irm | iex`, because the content arrives # as an expression rather than as a script file. Check at runtime so an old # host fails with a sentence instead of a pile of missing-cmdlet errors. if ($PSVersionTable.PSVersion.Major -lt 5) { Write-Warning "This script needs PowerShell 5.1 or newer; this is $($PSVersionTable.PSVersion)." Write-HubNote 'Windows 10 and 11 ship 5.1 as standard, so an older host is unusual.' return } $hubBase = if ($env:HUB_BASE) { $env:HUB_BASE } else { 'https://hub.skillnav.dev' } $dryRun = ($env:HUB_DRY_RUN -eq '1') $doFix = ($env:HUB_FIX -eq '1') Write-HubLog "hub net check - $([System.Environment]::OSVersion.Version.ToString()), PowerShell $($PSVersionTable.PSVersion)" Write-HubNote 'Probing is read-only; this takes a few seconds.' Write-Host '' # --- collect ------------------------------------------------------------ $adapter = Get-HubWifiAdapter if (-not $adapter) { Add-HubRow -Status 'FAIL' -Layer 'interface' -Detail 'no Wi-Fi adapter found on this machine' Show-HubTable Write-HubLog 'Verdict' Write-HubNote 'Windows reports no Wi-Fi adapter at all, so the rest of the stack' Write-HubNote 'cannot be tested. Confirm what the system sees:' Write-HubNote ' Get-NetAdapter | Format-Table Name, InterfaceDescription, Status' Write-HubNote 'If nothing wireless is listed, the adapter is disabled, the driver is' Write-HubNote 'missing, or the hardware radio switch is off.' Write-Host '' Write-HubNote "Cheatsheet: $hubBase/commands/win/" return } $idx = $adapter.InterfaceIndex $adapterNm = $adapter.Name # SSID and signal. netsh is the only source for these, but "SSID" and # "BSSID" are not translated, so anchoring at line start is safe on a # localised Windows. ^\s*SSID cannot match the B of BSSID. $ssid = '' $signal = '' # The restore lives in `finally`, not in the happy path. If netsh.exe is # missing or blocked by policy the call throws, control jumps to the catch, # and an in-line restore would be skipped - leaving the remaining ~500 lines # of this function running under 'Continue'. That failure is silent and # permissive rather than loud, which is exactly why it would go unnoticed. $previous = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' $wlan = (& netsh.exe wlan show interfaces 2>$null | Out-String) $ErrorActionPreference = $previous if ($wlan -match '(?m)^\s*SSID\s*:\s*(.+?)\s*$') { $ssid = $Matches[1] } # An SSID is whatever the access point claims it is, and a hostile one # can claim control bytes or terminal escapes. It is only ever displayed # here and never placed into a command, but stripping control characters # keeps a hostile name from scrambling the console output. if ($ssid) { $ssid = ($ssid -replace '\p{C}', '') } if ($wlan -match '(?m)^\s*(?:Signal|信号)\s*:\s*(.+?)\s*$') { $signal = $Matches[1] } } catch { } finally { $ErrorActionPreference = $previous } # IPv4 and how it was obtained. PrefixOrigin is the language-independent # answer to "is this a static address someone typed in". $ip = '' $ipOrigin = '' try { $ipInfo = Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^127\.' } | Select-Object -First 1 if ($ipInfo) { $ip = $ipInfo.IPAddress $ipOrigin = [string] $ipInfo.PrefixOrigin } } catch { } $isStaticIp = ($ipOrigin -eq 'Manual') # Default gateway, lowest metric wins. $gateway = '' try { $route = Get-NetRoute -InterfaceIndex $idx -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1 if ($route) { $gateway = $route.NextHop } } catch { } $gatewayOk = Test-HubPing -Target $gateway # Raw IPs, so a broken resolver cannot spoil this stage. $wanOk = $false $wanVia = '' if (Test-HubPing -Target $HubPingA -TimeoutMs 2000) { $wanOk = $true; $wanVia = $HubPingA } elseif (Test-HubPing -Target $HubPingB -TimeoutMs 2000) { $wanOk = $true; $wanVia = $HubPingB } # IPv6: only a routable address counts. WellKnown/link-local addresses are # always present and prove nothing. $v6Addr = '' $v6Ok = $false try { $v6 = Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^fe80' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -First 1 if ($v6) { $v6Addr = $v6.IPAddress $v6Ok = Test-HubPing -Target '2400:3200::1' -Count 1 -TimeoutMs 2000 } } catch { } # Static DNS is recorded per-interface in the registry. Reading it there # avoids parsing localised netsh output. $dnsManual = $false try { $guid = $adapter.InterfaceGuid if ($guid) { $key = "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\$guid" $ns = (Get-ItemProperty -Path $key -Name 'NameServer' -ErrorAction SilentlyContinue).NameServer $dnsManual = -not [string]::IsNullOrWhiteSpace($ns) } } catch { } $dnsServers = '' try { $addresses = (Get-DnsClientServerAddress -InterfaceIndex $idx -AddressFamily IPv4 -ErrorAction SilentlyContinue).ServerAddresses if ($addresses) { $dnsServers = ($addresses -join ' ') } } catch { } # What the system resolver actually does - the user's real experience. $dnsOk = $false try { $resolved = Resolve-DnsName -Name $HubProbeHost -Type A -QuickTimeout -ErrorAction SilentlyContinue $dnsOk = [bool] ($resolved | Where-Object { $_.IPAddress }) } catch { } if (-not $dnsOk) { # Resolve-DnsName is absent on some trimmed installs. Fall back to .NET # rather than to nslookup: parsing nslookup means parsing translated # output, and reporting a false DNS failure on a localised Windows is # exactly the wrong answer to give someone who is already stuck. try { $addresses = [System.Net.Dns]::GetHostAddresses($HubProbeHost) $dnsOk = [bool] ($addresses | Select-Object -First 1) } catch { } } # Asking a public resolver directly separates "the servers this network # handed us are wrong" from "name resolution is broken everywhere". $dnsPubTested = $false $dnsPubOk = $false try { $direct = Resolve-DnsName -Name $HubProbeHost -Server $HubPublicDns -Type A -QuickTimeout -ErrorAction SilentlyContinue $dnsPubTested = $true $dnsPubOk = [bool] ($direct | Where-Object { $_.IPAddress }) } catch { } # HTTP / captive portal. A redirect or a 200 where a 204 belongs means # something is sitting in the middle. $httpCode = 0 try { $resp = Invoke-WebRequest -Uri $HubProbeUrl -UseBasicParsing -TimeoutSec 8 -MaximumRedirection 0 -ErrorAction Stop $httpCode = [int] $resp.StatusCode } catch { try { $response = $_.Exception.Response if ($response -and $response.StatusCode) { $httpCode = [int] $response.StatusCode } } catch { $httpCode = 0 } } # Plain HTTP succeeding proves less than it looks: a captive portal answers # port 80 by design, and a firewall can allow 80 while dropping 443. Nearly # everything a person actually opens is HTTPS, so test it separately rather # than declaring "every layer passes" on the strength of port 80 alone. $httpsCode = 0 try { $tls = Invoke-WebRequest -Uri $HubProbeTls -UseBasicParsing -TimeoutSec 8 -MaximumRedirection 0 -ErrorAction Stop $httpsCode = [int] $tls.StatusCode } catch { try { $response = $_.Exception.Response if ($response -and $response.StatusCode) { $httpsCode = [int] $response.StatusCode } } catch { $httpsCode = 0 } } # Sidecar facts. $proxyDesc = '' try { $isKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' $isVal = Get-ItemProperty -Path $isKey -ErrorAction SilentlyContinue if ($isVal.ProxyEnable -eq 1) { $proxyDesc += 'manual ' } if (-not [string]::IsNullOrWhiteSpace($isVal.AutoConfigURL)) { $proxyDesc += 'pac ' } } catch { } if ($env:HTTP_PROXY -or $env:HTTPS_PROXY -or $env:ALL_PROXY) { $proxyDesc += 'env ' } $hostsExtra = 0 try { $hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' # Exclude the stock localhost mappings by HOSTNAME, not by address. # Filtering out every line starting with 127.0.0.1 would hide # `127.0.0.1 example.com` - the single most common way one specific # site gets blocked, and exactly what someone running this needs told. $hostsExtra = @(Get-Content -Path $hostsPath -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch '^\s*#' -and $_ -notmatch '^\s*$' -and $_ -notmatch '^\s*\S+\s+(localhost|broadcasthost|localhost\.localdomain)\s*$' }).Count } catch { } $mtu = '' try { $iface = Get-NetIPInterface -InterfaceIndex $idx -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1 if ($iface) { $mtu = [string] $iface.NlMtu } } catch { } # MAC randomisation, which Windows exposes cleanly: the current address # differs from the burned-in one. This is the detail that explains "the # router lets everyone else on but not me". $macRandomised = $false $macCurrent = [string] $adapter.MacAddress try { # These two properties are not formatted the same way - one comes back # as 00-11-22-33-44-55 and the other as 001122334455 - so compare them # only after stripping separators. Skipping this step would report # every adapter as randomised. An all-zero permanent address means the # driver does not expose one, which proves nothing either way. $permanent = [string] $adapter.PermanentAddress $normCurrent = ($macCurrent -replace '[^0-9A-Fa-f]', '').ToUpper() $normPerm = ($permanent -replace '[^0-9A-Fa-f]', '').ToUpper() if ($normCurrent -and $normPerm -and ($normPerm -ne '000000000000') -and ($normCurrent -ne $normPerm)) { $macRandomised = $true } } catch { } # --- build the table ---------------------------------------------------- # Row order IS the diagnosis order: it reads bottom-up through the stack, # so the first FAIL is the real problem. # ICMP is filtered by plenty of routers and firewalls that still forward # ordinary traffic perfectly well. When a name resolves AND the probe URL # returns 204, the path is demonstrably working end to end, so a silent # gateway is inconclusive rather than broken. Without this, a filtered ping # outranks the proof that everything works, and the verdict tells a # static-IP user to discard a configuration that is doing its job. $connectivityProven = ($dnsOk -and (($httpCode -eq 204) -or ($httpsCode -eq 204))) if (Test-HubAdapterUp $adapter) { Add-HubRow -Status 'PASS' -Layer 'interface' -Detail "$adapterNm is up" } else { Add-HubRow -Status 'FAIL' -Layer 'interface' -Detail "$adapterNm is $($adapter.Status)" } if ($ssid) { $linkDetail = "associated with `"$ssid`"" if ($signal) { $linkDetail += " (signal $signal)" } Add-HubRow -Status 'PASS' -Layer 'link' -Detail $linkDetail } elseif (Test-HubAdapterUp $adapter) { # Windows 11 withholds the current SSID unless location access has been # granted, so an unreadable SSID does NOT mean "not associated". The # adapter being Up is the trustworthy signal. Reporting FAIL here would # hand a perfectly connected machine a bottom-layer misdiagnosis and # stop the verdict from ever looking at the real problem. Add-HubRow -Status 'SKIP' -Layer 'link' -Detail 'associated, but the SSID is unreadable (Windows 11 needs location access)' } else { Add-HubRow -Status 'FAIL' -Layer 'link' -Detail 'not associated with any network' } if (-not $ip) { Add-HubRow -Status 'FAIL' -Layer 'ipv4' -Detail "no IPv4 address on $adapterNm" } elseif ($ip -like '169.254.*') { Add-HubRow -Status 'FAIL' -Layer 'ipv4' -Detail "$ip is self-assigned, DHCP never answered" } else { Add-HubRow -Status 'PASS' -Layer 'ipv4' -Detail $ip } # A static address is only a problem when its router is unreachable, which # is exactly the "works on the other network" pattern. if ($isStaticIp -and (-not $gatewayOk) -and (-not $connectivityProven)) { Add-HubRow -Status 'FAIL' -Layer 'ip config' -Detail "static IP on $adapterNm, and its router does not answer" } elseif ($isStaticIp) { Add-HubRow -Status 'PASS' -Layer 'ip config' -Detail 'static IP (router answers, so it fits this network)' } else { Add-HubRow -Status 'PASS' -Layer 'ip config' -Detail $(if ($ipOrigin) { $ipOrigin } else { 'unknown' }) } if (-not $gateway) { Add-HubRow -Status 'FAIL' -Layer 'gateway' -Detail "no default route on $adapterNm" } elseif ($gatewayOk) { Add-HubRow -Status 'PASS' -Layer 'gateway' -Detail "$gateway answers" } elseif ($connectivityProven) { Add-HubRow -Status 'SKIP' -Layer 'gateway' -Detail "$gateway ignores ping, but traffic reaches the internet - ICMP is filtered" } else { Add-HubRow -Status 'FAIL' -Layer 'gateway' -Detail "$gateway does not answer" } if ($wanOk) { Add-HubRow -Status 'PASS' -Layer 'internet' -Detail "$wanVia reachable by IP" } elseif ($connectivityProven) { Add-HubRow -Status 'SKIP' -Layer 'internet' -Detail 'ping is filtered, but DNS and HTTP both succeed' } else { Add-HubRow -Status 'FAIL' -Layer 'internet' -Detail "neither $HubPingA nor $HubPingB answers" } if ($dnsManual -and (-not $dnsOk)) { Add-HubRow -Status 'FAIL' -Layer 'dns config' -Detail "manually set on ${adapterNm}: $dnsServers" } elseif ($dnsManual) { Add-HubRow -Status 'PASS' -Layer 'dns config' -Detail "manually set: $dnsServers (and they work)" } else { Add-HubRow -Status 'PASS' -Layer 'dns config' -Detail "from DHCP: $(if ($dnsServers) { $dnsServers } else { 'none reported' })" } if ($dnsOk) { Add-HubRow -Status 'PASS' -Layer 'dns resolve' -Detail "$HubProbeHost resolves" } elseif ($dnsPubTested -and $dnsPubOk) { Add-HubRow -Status 'FAIL' -Layer 'dns resolve' -Detail "system resolver fails, but $HubPublicDns answers directly" } else { Add-HubRow -Status 'FAIL' -Layer 'dns resolve' -Detail "$HubProbeHost does not resolve" } if ($httpCode -eq 204) { Add-HubRow -Status 'PASS' -Layer 'http' -Detail '204 from the captive-portal probe, egress is clean' } elseif ($httpCode -eq 0) { Add-HubRow -Status 'FAIL' -Layer 'http' -Detail "no response from $HubProbeUrl" } else { Add-HubRow -Status 'FAIL' -Layer 'http' -Detail "expected 204, got $httpCode - something is intercepting HTTP" } # The one that actually matters for real browsing. if ($httpsCode -eq 204) { Add-HubRow -Status 'PASS' -Layer 'https' -Detail '204 over TLS, ordinary browsing should work' } elseif ($httpsCode -eq 0) { Add-HubRow -Status 'FAIL' -Layer 'https' -Detail 'no response over TLS while plain HTTP behaved differently' } else { Add-HubRow -Status 'FAIL' -Layer 'https' -Detail "expected 204 over TLS, got $httpsCode" } # Deliberately ranked BELOW dns and http. A dead IPv6 path makes pages hang # and then load; a dead resolver makes nothing load at all. Ranking it # higher would report "IPv6 is slow" to someone whose real problem is that # no name resolves. if (-not $v6Addr) { Add-HubRow -Status 'SKIP' -Layer 'ipv6' -Detail 'no routable IPv6 address, IPv4 only' } elseif ($v6Ok) { Add-HubRow -Status 'PASS' -Layer 'ipv6' -Detail 'routable and reachable' } else { Add-HubRow -Status 'FAIL' -Layer 'ipv6' -Detail "$v6Addr is configured but nothing answers over IPv6" } # Sidecar rows: never the primary verdict, but worth seeing. if ($macRandomised) { Add-HubRow -Status 'SKIP' -Layer 'mac' -Detail "randomised ($macCurrent is not the hardware address)" } else { Add-HubRow -Status 'PASS' -Layer 'mac' -Detail "hardware address in use ($macCurrent)" } if ($proxyDesc) { Add-HubRow -Status 'SKIP' -Layer 'proxy' -Detail "enabled: $proxyDesc- check it if the layers above all pass" } else { Add-HubRow -Status 'PASS' -Layer 'proxy' -Detail 'none set' } if ($hostsExtra -gt 0) { Add-HubRow -Status 'SKIP' -Layer 'hosts' -Detail "$hostsExtra custom entr(ies) in the hosts file" } else { Add-HubRow -Status 'PASS' -Layer 'hosts' -Detail 'no custom entries' } if ((-not $mtu) -or ($mtu -eq '1500')) { Add-HubRow -Status 'PASS' -Layer 'mtu' -Detail $(if ($mtu) { $mtu } else { 'unknown' }) } else { Add-HubRow -Status 'SKIP' -Layer 'mtu' -Detail "$mtu, not the usual 1500" } Show-HubTable # --- verdict ------------------------------------------------------------ Write-HubLog 'Verdict' # The all-clear is handled before the switch rather than as an '' case: # matching an empty string in a switch is a corner worth not standing in. # An empty value then simply matches no case, and `default` stays quiet. if (-not $script:HubFirstFail) { Write-HubOk 'Every layer passes. If pages still fail, the problem is above the' Write-HubNote 'network: a single site being down, a browser profile, or an app-level proxy.' if ($proxyDesc) { Write-HubNote "A proxy IS configured ($proxyDesc). That is the first thing to check." } } switch ($script:HubFirstFail) { 'interface' { Write-HubNote "The Wi-Fi adapter $adapterNm is not up, so nothing above it can work." Write-HubNote 'Check the hardware radio switch or the airplane-mode toggle first, then:' Write-HubNote " Enable-NetAdapter -Name '$adapterNm' # needs an admin PowerShell" } 'link' { Write-HubNote 'The adapter is up but not associated with any network.' Write-HubNote 'Pick the network from the taskbar first, then re-run this script.' } 'ipv4' { Write-HubNote 'The router never handed this laptop a usable address.' Write-HubNote 'Safe to try, and what HUB_FIX does (needs an admin PowerShell):' Write-HubNote ' ipconfig /renew' Write-HubNote '' Write-HubNote 'If it stays at 169.254.x.x after that, the cause is on the router:' Write-HubNote 'a full DHCP pool, or this laptop being blocked by a MAC filter. Other' Write-HubNote 'people work because their devices are already known to it.' if ($macRandomised) { Write-HubNote '' Write-HubNote 'Worth knowing: this adapter is using a RANDOM MAC address right now' Write-HubNote "($macCurrent is not the hardware address). To a router that binds or" Write-HubNote 'filters by MAC, this laptop looks like a brand new stranger every time.' Write-HubNote 'That single setting explains the whole symptom. Turn it off for this' Write-HubNote 'network - it CHANGES a setting, so it is your call:' Write-HubNote ' Settings > Network & Internet > Wi-Fi > Manage known networks >' Write-HubNote ' (this network) > Random hardware addresses > Off' } } 'ip config' { Write-HubNote "A static IP is configured on $adapterNm and its router does not answer." Write-HubNote 'This is the classic leftover: the address belongs to another network,' Write-HubNote 'which is exactly why this laptop works there and fails here.' Write-HubNote '' Write-HubNote 'Switching back to automatic will DISCARD that static configuration, so' Write-HubNote 'this script will not do it for you. Write the current values down first:' Write-HubNote " Get-NetIPAddress -InterfaceIndex $idx -AddressFamily IPv4" Write-HubNote 'Then, when you are sure (admin PowerShell):' Write-HubNote " Set-NetIPInterface -InterfaceIndex $idx -Dhcp Enabled" Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ResetServerAddresses" } 'gateway' { # Two different faults share this row and they need different advice. # Without this split the no-route case prints "the router at does # not answer" - address missing - and then offers MAC-filter and # IP-collision theories that cannot apply when there is no route. if (-not $gateway) { Write-HubNote "This laptop has an address but no default route on $adapterNm at all." Write-HubNote 'DHCP handed out an address and then failed to supply a gateway, so' Write-HubNote 'nothing can leave the subnet. Neither a MAC filter nor an IP collision' Write-HubNote 'explains this one - the routing table is simply empty.' Write-HubNote '' Write-HubNote 'See what the interface actually got:' Write-HubNote " Get-NetRoute -InterfaceIndex $idx | Format-Table DestinationPrefix, NextHop, RouteMetric" Write-HubNote '' Write-HubNote 'A guest VLAN handing out addresses without a router is the usual' Write-HubNote "cause, and that is the network's problem, not this laptop's." } else { Write-HubNote "This laptop has an address but the router at $gateway does not answer." Write-HubNote 'The Wi-Fi association succeeded, so this is not a password problem.' Write-HubNote 'Two causes fit "others are fine, this laptop is not":' Write-HubNote '' Write-HubNote ' 1. The router is filtering this device by MAC address. Show yours:' Write-HubNote " Get-NetAdapter -InterfaceIndex $idx | Format-List Name, MacAddress, PermanentAddress" if ($macRandomised) { Write-HubNote " This adapter is currently using a RANDOM MAC ($macCurrent), so any" Write-HubNote ' allow-list entry stops matching. Turn randomisation off for this' Write-HubNote ' network before asking for the address to be allowed:' Write-HubNote ' Settings > Network & Internet > Wi-Fi > Manage known networks >' Write-HubNote ' (this network) > Random hardware addresses > Off' } Write-HubNote '' Write-HubNote ' 2. Another device has taken the same IP. Renewing moves this laptop' Write-HubNote ' off the collision (admin PowerShell, this adapter only):' Write-HubNote " ipconfig /renew `"$adapterNm`"" } } 'internet' { Write-HubNote 'The router answers but nothing beyond it does. The link is fine and the' Write-HubNote 'problem is the network policy toward this device.' Write-HubNote '' Write-HubNote ' 1. Captive portal. Hotel, office guest and campus networks hold new' Write-HubNote ' devices until they accept a login page. Everyone else got through' Write-HubNote ' it already. Force it open:' Write-HubNote ' start http://cp.cloudflare.com' Write-HubNote '' Write-HubNote ' 2. This device is blocked upstream. Same MAC check as above:' Write-HubNote " Get-NetAdapter -InterfaceIndex $idx | Format-List Name, MacAddress, PermanentAddress" } { $_ -eq 'dns config' -or $_ -eq 'dns resolve' } { Write-HubNote 'Raw IP addresses work, names do not. This is DNS, and DNS alone.' if ($dnsManual) { Write-HubNote '' Write-HubNote "DNS servers are set BY HAND on ${adapterNm}: $dnsServers" Write-HubNote 'Those almost certainly belong to another network - a company or home' Write-HubNote 'resolver that simply does not exist here. That single setting explains' Write-HubNote 'the whole symptom, including why other networks still work.' Write-HubNote '' Write-HubNote 'Clearing it hands DNS back to DHCP, and DISCARDS what is set now' Write-HubNote '(admin PowerShell):' Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ResetServerAddresses" Write-HubNote 'Or point it at a public resolver instead:' Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ServerAddresses $HubPublicDns,1.1.1.1" } elseif ($dnsPubTested -and $dnsPubOk) { Write-HubNote '' Write-HubNote "$HubPublicDns answers when asked directly, so the network path is fine." Write-HubNote "The resolvers this network handed out ($dnsServers) are not answering." Write-HubNote 'Override them for this adapter (admin PowerShell):' Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ServerAddresses $HubPublicDns,1.1.1.1" Write-HubNote 'To go back to whatever DHCP provides:' Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ResetServerAddresses" } else { Write-HubNote '' Write-HubNote 'Start with a cache flush, which HUB_FIX does and which changes nothing else:' Write-HubNote ' ipconfig /flushdns' Write-HubNote 'If names still fail afterwards, set a public resolver (admin PowerShell):' Write-HubNote " Set-DnsClientServerAddress -InterfaceIndex $idx -ServerAddresses $HubPublicDns,1.1.1.1" } } 'ipv6' { Write-HubNote 'IPv6 is configured on this network but nothing answers over it, while' Write-HubNote 'IPv4 is healthy. Windows prefers IPv6, so connections wait for the v6' Write-HubNote 'attempt to time out first - pages hang, then eventually load.' Write-HubNote '' Write-HubNote 'This is a fault in the network, not in this laptop, which is why other' Write-HubNote 'networks feel fine. Disabling IPv6 on this adapter is a workaround, and' Write-HubNote 'it CHANGES a setting, so decide it yourself (admin PowerShell):' Write-HubNote " Disable-NetAdapterBinding -Name '$adapterNm' -ComponentID ms_tcpip6" Write-HubNote 'To undo it later:' Write-HubNote " Enable-NetAdapterBinding -Name '$adapterNm' -ComponentID ms_tcpip6" } 'http' { Write-HubNote 'Names resolve and IPs are reachable, but the HTTP probe did not return' Write-HubNote "the expected 204 - it got: $(if ($httpCode) { $httpCode } else { 'no response' })." Write-HubNote '' Write-HubNote 'A 200 or a redirect here means something is intercepting HTTP, which is' Write-HubNote 'what a captive portal does. Open it and complete the login:' Write-HubNote ' start http://cp.cloudflare.com' if ($proxyDesc) { Write-HubNote '' Write-HubNote "A proxy is also configured ($proxyDesc). If it points at something that" Write-HubNote 'only exists on another network, that fits the symptom too. Inspect it' Write-HubNote 'before changing anything:' Write-HubNote " Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' | Format-List ProxyEnable, ProxyServer, AutoConfigURL" Write-HubNote ' netsh winhttp show proxy' } } 'https' { Write-HubNote "Plain HTTP works but HTTPS does not ($(if ($httpsCode) { $httpsCode } else { 'no response' }))." Write-HubNote 'Names resolve and port 80 behaves, so this is not a general outage.' Write-HubNote 'Something is blocking or intercepting TLS on port 443 specifically,' Write-HubNote 'and since nearly every site is HTTPS, that looks like "no internet".' Write-HubNote '' Write-HubNote ' 1. A captive portal deliberately lets port 80 through so it can show' Write-HubNote ' you its login page, and holds everything else. Open it:' Write-HubNote ' start http://cp.cloudflare.com' Write-HubNote '' Write-HubNote ' 2. A TLS-inspecting firewall whose certificate this machine does not' Write-HubNote ' trust - common on corporate and campus networks.' Write-HubNote '' Write-HubNote ' 3. A proxy that only handles plain HTTP:' Write-HubNote ' netsh winhttp show proxy' } default { # Reached either for an unmapped layer name or for the all-clear, # which was already reported above - so say nothing in that case. if ($script:HubFirstFail) { Write-HubNote "Unexpected first failure: $($script:HubFirstFail)" } } } Write-Host '' # --- fix ---------------------------------------------------------------- # HUB_DRY_RUN on its own is enough to reach the fix block. Gating this on # HUB_FIX alone made the documented preview a dead path: someone who set # only HUB_DRY_RUN got the "re-run with the fix enabled" hint and never saw # a single DRY line, which reads as the flag being broken. if ($doFix -or $dryRun) { # Require positive proof of DHCP. Testing `-not $isStaticIp` would also # be true when PrefixOrigin could not be read at all, and renewing on a # failed detection is exactly the case where it could do harm. Invoke-HubNetFix -InterfaceIndex $idx -IsDhcp ($ipOrigin -eq 'Dhcp') -AdapterName $adapterNm -DryRun:$dryRun Write-Host '' } elseif ($script:HubFails -gt 0) { Write-HubNote 'Re-run with the fix enabled to flush DNS and renew the DHCP lease:' Write-HubNote " `$env:HUB_FIX = '1'; irm $hubBase/net.ps1 | iex" Write-Host '' } Write-HubNote "Cheatsheet: $hubBase/commands/win/" } function Show-HubTable { foreach ($row in $script:HubRows) { Write-Host ('{0,-5} {1,-12} {2}' -f $row.Status, $row.Layer, $row.Detail) } Write-Host '' } function Invoke-HubNetFix { param( [Parameter(Mandatory = $true)][int] $InterfaceIndex, [Parameter(Mandatory = $true)][bool] $IsDhcp, [Parameter(Mandatory = $true)][string] $AdapterName, [switch] $DryRun ) Write-HubLog 'Fix - only the two actions that cannot lose anything' Write-HubNote 'Both are idempotent, both are scoped to this one adapter, and' Write-HubNote 'neither overwrites a setting you chose.' Write-Host '' $isAdmin = Test-HubAdmin # 1. Flush the DNS cache. No elevation needed, no side effects. if ($DryRun) { Write-HubDry 'ipconfig /flushdns' } else { Write-HubLog 'flushing the DNS cache' $previous = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' & ipconfig.exe /flushdns | Out-Null if ($LASTEXITCODE -eq 0) { Write-HubOk 'DNS cache flushed' } else { Write-Warning 'ipconfig /flushdns did not report success' } } catch { Write-Warning "could not run ipconfig /flushdns: $($_.Exception.Message)" } finally { $ErrorActionPreference = $previous } } # 2. Renew the DHCP lease, but ONLY when the adapter is already on DHCP. # Renewing a statically configured adapter would overwrite the address # someone deliberately set, which is out of bounds for this script. # Always name the adapter. A bare `ipconfig /renew` renews EVERY DHCP # adapter on the machine, so diagnosing Wi-Fi would also bounce Ethernet # and any VPN tunnel - connections this script was never asked to touch. if (-not $IsDhcp) { Write-HubNote "Skipping the DHCP renew: $AdapterName is not confirmed to be on DHCP." Write-HubNote 'Renewing could overwrite a configuration you chose, so it is your call:' Write-HubNote " ipconfig /renew `"$AdapterName`"" } elseif ($DryRun) { Write-HubDry "ipconfig /renew `"$AdapterName`"" } elseif (-not $isAdmin) { Write-Warning 'Renewing the DHCP lease needs an elevated PowerShell.' Write-HubNote 'Open PowerShell as administrator and run this one yourself:' Write-HubNote " ipconfig /renew `"$AdapterName`"" } else { Write-HubLog "renewing the DHCP lease on $AdapterName" $previous = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' & ipconfig.exe /renew $AdapterName | Out-Null if ($LASTEXITCODE -eq 0) { Write-HubOk 'DHCP lease renewed' } else { Write-Warning 'ipconfig /renew did not report success' } } catch { Write-Warning "could not run ipconfig /renew: $($_.Exception.Message)" } finally { $ErrorActionPreference = $previous } } Write-Host '' Write-HubNote 'Re-run without HUB_FIX to see whether the table changed.' } Invoke-HubNetCheck