#!/bin/sh # net.sh - "connected to Wi-Fi but nothing loads" diagnosis for macOS # # Purpose : You are associated with a Wi-Fi network, other people on that same # network are fine, and this Mac works on other networks. Walk the # stack from the interface upwards and name the FIRST layer that # breaks, then print the command that fixes that specific layer. # Usage : curl -fsSL https://hub.skillnav.dev/net.sh | sh # curl -fsSL https://hub.skillnav.dev/net.sh | sh -s -- --fix # Idempotent: yes. The default run is strictly read-only. --fix performs ONE # action, flushing the DNS cache, which lands on the same state no # matter how often it runs. Renewing the DHCP lease is deliberately # not automated: the only command for it, `ipconfig set DHCP`, # de-configures existing IPv4 services first (see ipconfig(8)), so # --fix prints it instead of running it. # Dry run : HUB_DRY_RUN=1 prints what --fix would do instead of doing it. # The read-only probing still runs; it has nothing to suppress. # Local test: HUB_BASE overrides the base URL used in printed links. # # This script never rewrites network settings, never forgets a Wi-Fi password, # never touches /etc/hosts or the proxy configuration, and never deletes # anything. 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. set -eu HUB_BASE="${HUB_BASE:-https://hub.skillnav.dev}" HUB_DRY_RUN="${HUB_DRY_RUN:-0}" # --------------------------------------------------------------------------- # Probe targets. Two ping targets on purpose: one anycast address being dropped # somewhere upstream should not produce a false "no internet at all" verdict. PING_IP_A="223.5.5.5" # AliDNS, reachable from inside mainland China PING_IP_B="1.1.1.1" # Cloudflare, reachable from most other places PUBLIC_DNS="223.5.5.5" # used for a direct lookup that bypasses system DNS PROBE_HOST="www.apple.com" PROBE_URL="http://cp.cloudflare.com/generate_204" PROBE_URL_TLS="https://cp.cloudflare.com/generate_204" # --------------------------------------------------------------------------- log() { printf '==> %s\n' "$*"; } ok() { printf 'ok %s\n' "$*"; } note() { printf ' %s\n' "$*"; } warn() { printf '!! %s\n' "$*" >&2; } dry() { printf 'DRY %s\n' "$*"; } # --- result table ---------------------------------------------------------- # Rows are collected as "STATUS|layer|detail" and printed in one aligned block # at the end. FIRST_FAIL remembers the lowest layer that failed, which is the # only thing the verdict actually needs: everything above a broken layer is # noise, not a second problem. CHECK_FAILS=0 CHECK_ROWS="" FIRST_FAIL="" check_row() { # The table is rendered by splitting rows on '|', and an SSID may legitimately # contain one, which would shift every column after it. Strip it here, at the # boundary that owns the encoding, rather than at each call site. _row_detail=$(printf '%s' "$3" | tr '|\n' '/ ') CHECK_ROWS="${CHECK_ROWS}$1|$2|${_row_detail} " if [ "$1" = "FAIL" ]; then CHECK_FAILS=$((CHECK_FAILS + 1)) if [ -z "$FIRST_FAIL" ]; then FIRST_FAIL="$2" fi fi return 0 } # --- collected facts ------------------------------------------------------- NET_SVC="" # network service name, e.g. "Wi-Fi" NET_DEV="" # BSD device name, e.g. "en0" HAS_IF=0 IF_UP=0 NET_SSID="" NET_IP="" NET_CFG="unknown" # DHCP | Manual | BOOTP | unknown NET_GW="" GW_OK=0 WAN_OK=0 WAN_VIA="" V6_ADDR="" V6_OK=0 DNS_SERVERS="" DNS_MANUAL=0 DNS_OK=0 DNS_PUB_OK=0 DNS_PUB_TESTED=0 HTTP_CODE="" HTTPS_CODE="" PROXY_DESC="" HOSTS_EXTRA=0 NET_MTU="" # --- probes ---------------------------------------------------------------- probe_interface() { # Do not hardcode en0: on Macs with a dock or a USB ethernet adapter the # Wi-Fi device is not always the first one. NET_SVC=$(networksetup -listnetworkserviceorder 2>/dev/null | awk ' /Hardware Port: Wi-Fi,/ { sub(/^\([0-9*]+\) /, "", prev); print prev; exit } { prev = $0 }' || true) NET_DEV=$(networksetup -listnetworkserviceorder 2>/dev/null | awk -F'Device: ' ' /Hardware Port: Wi-Fi,/ { sub(/\).*$/, "", $2); print $2; exit }' || true) if [ -z "$NET_DEV" ]; then NET_DEV="en0" fi if [ -z "$NET_SVC" ]; then NET_SVC="Wi-Fi" fi if ifconfig "$NET_DEV" >/dev/null 2>&1; then HAS_IF=1 # Match net.ps1, which requires the adapter to be operationally up. A # powered-off Wi-Fi radio still has an interface, so "ifconfig succeeded" # alone would PASS this row and fail one row later on the missing SSID - # sending someone off to pick a network from the menu bar when the radio # is simply switched off. if ifconfig "$NET_DEV" 2>/dev/null | grep -q 'status: active'; then IF_UP=1 fi fi } probe_link() { NET_SSID=$(networksetup -getairportnetwork "$NET_DEV" 2>/dev/null | sed -n 's/^Current Wi-Fi Network: //p' || true) if [ -z "$NET_SSID" ]; then # Some macOS builds stopped reporting the SSID through networksetup. NET_SSID=$(ipconfig getsummary "$NET_DEV" 2>/dev/null | sed -n 's/^[[:space:]]*SSID : //p' | head -n 1 || true) fi # An SSID is whatever the access point claims it is, and a hostile one can # claim control bytes or terminal escape sequences. It is only ever displayed # here and never executed, but a raw escape can still scramble the output on # its way to the terminal. NET_SSID=$(printf '%s' "$NET_SSID" | tr -d '[:cntrl:]') } probe_ipv4() { NET_IP=$(ipconfig getifaddr "$NET_DEV" 2>/dev/null || true) info=$(networksetup -getinfo "$NET_SVC" 2>/dev/null || true) case "$info" in *"Manual Configuration"*) NET_CFG="Manual" ;; *"DHCP Configuration"*) NET_CFG="DHCP" ;; *"BOOTP Configuration"*) NET_CFG="BOOTP" ;; *) NET_CFG="unknown" ;; esac } probe_gateway() { # Scoped to the Wi-Fi device on purpose, with no machine-wide fallback. The # obvious `route -n get default` would happily return an Ethernet, dock or # VPN gateway, and then the table would describe this interface using another # one's working route. "No default route on this interface" is the honest # answer and is itself a useful finding. NET_GW=$(netstat -rnf inet 2>/dev/null | awk -v d="$NET_DEV" '$1 == "default" && $NF == d { print $2; exit }' || true) if [ -n "$NET_GW" ]; then if ping -c 2 -t 3 "$NET_GW" >/dev/null 2>&1; then GW_OK=1 fi fi } probe_wan() { # Ping raw IPs so this stage cannot be spoiled by a broken resolver. if ping -c 2 -t 4 "$PING_IP_A" >/dev/null 2>&1; then WAN_OK=1 WAN_VIA="$PING_IP_A" elif ping -c 2 -t 4 "$PING_IP_B" >/dev/null 2>&1; then WAN_OK=1 WAN_VIA="$PING_IP_B" fi } probe_ipv6() { # Skip link-local and temporary addresses: only a routable one matters here. V6_ADDR=$(ifconfig "$NET_DEV" 2>/dev/null | awk '/inet6 / && $2 !~ /^fe80/ && $0 !~ /temporary/ { print $2; exit }' || true) if [ -n "$V6_ADDR" ]; then if ping6 -c 1 -i 1 2400:3200::1 >/dev/null 2>&1; then V6_OK=1 fi fi } probe_dns() { servers=$(networksetup -getdnsservers "$NET_SVC" 2>/dev/null || true) case "$servers" in *"aren't any DNS Servers"*|"") DNS_MANUAL=0 DNS_SERVERS=$(scutil --dns 2>/dev/null | awk '/nameserver\[/ { print $3 }' | sort -u | tr '\n' ' ' || true) ;; *) DNS_MANUAL=1 DNS_SERVERS=$(printf '%s' "$servers" | tr '\n' ' ') ;; esac # What the system resolver actually does - this is the user's experience. if dscacheutil -q host -a name "$PROBE_HOST" 2>/dev/null | grep -qE 'ip_address|ipv6_address'; then DNS_OK=1 fi # Asking a public resolver directly separates "the DNS servers this network # handed us are wrong" from "name resolution is broken everywhere". if command -v dig >/dev/null 2>&1; then DNS_PUB_TESTED=1 # Accepting "any non-empty output" is not good enough: `dig +short` prints # diagnostics such as ";; connection timed out" on stdout, which would read # as success. Requiring an address-shaped line rejects that. # # It does NOT rescue the other case. A transparent proxy or TUN interface # answers queries aimed at a resolver that was never reachable - a query # sent to an unroutable address here still came back with a synthesised # address, and no check on this side can tell that apart from a real # answer. The proxy row is what surfaces that; this flag cannot. dns_pub_out=$(dig +short +time=3 +tries=1 "@${PUBLIC_DNS}" "$PROBE_HOST" 2>/dev/null || true) if printf '%s\n' "$dns_pub_out" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then DNS_PUB_OK=1 fi fi } probe_http() { HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -m 8 "$PROBE_URL" 2>/dev/null || true) # 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. HTTPS_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -m 8 "$PROBE_URL_TLS" 2>/dev/null || true) } probe_sidecar() { # Proxy settings scoped to this service, plus the environment variables that # a shell-launched curl would honour. web=$(networksetup -getwebproxy "$NET_SVC" 2>/dev/null | awk '/^Enabled:/ { print $2; exit }' || true) sec=$(networksetup -getsecurewebproxy "$NET_SVC" 2>/dev/null | awk '/^Enabled:/ { print $2; exit }' || true) pac=$(networksetup -getautoproxyurl "$NET_SVC" 2>/dev/null | awk '/^Enabled:/ { print $2; exit }' || true) # Written as full `if` blocks on purpose: under `set -e` a bare `test && x` # aborts the whole script the moment the test is false. if [ "$web" = "Yes" ]; then PROXY_DESC="${PROXY_DESC}http "; fi if [ "$sec" = "Yes" ]; then PROXY_DESC="${PROXY_DESC}https "; fi if [ "$pac" = "Yes" ]; then PROXY_DESC="${PROXY_DESC}pac "; fi if [ -n "${http_proxy:-}${https_proxy:-}${all_proxy:-}" ]; then PROXY_DESC="${PROXY_DESC}env " fi # Exclude the stock localhost mappings by HOSTNAME, not by address. Filtering # out every line that starts with 127.0.0.1 would hide `127.0.0.1 example.com` # - which is the single most common way a specific site gets blocked, and # exactly the thing someone running this script needs to be told about. HOSTS_EXTRA=$(grep -Ecv '^[[:space:]]*#|^[[:space:]]*$|^[^[:space:]]+[[:space:]]+(localhost|broadcasthost|localhost\.localdomain)[[:space:]]*$' /etc/hosts 2>/dev/null || true) NET_MTU=$(networksetup -getMTU "$NET_DEV" 2>/dev/null | sed -n 's/^Active MTU: \([0-9]*\).*/\1/p' || true) return 0 } probe_all() { probe_interface probe_link probe_ipv4 probe_gateway probe_wan probe_ipv6 probe_dns probe_http probe_sidecar } # --- report ---------------------------------------------------------------- # Row order IS the diagnosis order: the table reads bottom-up through the # stack, so the first FAIL is the real problem and the rest is fallout. build_rows() { # 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. CONN_PROVEN=0 if [ "$DNS_OK" -eq 1 ]; then if [ "$HTTP_CODE" = "204" ] || [ "$HTTPS_CODE" = "204" ]; then CONN_PROVEN=1 fi fi # 1. interface if [ "$HAS_IF" -eq 0 ]; then check_row FAIL "interface" "no Wi-Fi device found (looked for ${NET_DEV})" elif [ "$IF_UP" -eq 1 ]; then check_row PASS "interface" "${NET_SVC} on ${NET_DEV}" else check_row FAIL "interface" "${NET_SVC} (${NET_DEV}) is not active - the radio is off" fi # 2. link if [ -n "$NET_SSID" ]; then check_row PASS "link" "associated with \"${NET_SSID}\"" else check_row FAIL "link" "not associated with any network" fi # 3. ipv4 case "$NET_IP" in "") check_row FAIL "ipv4" "no IPv4 address on ${NET_DEV}" ;; 169.254.*) check_row FAIL "ipv4" "${NET_IP} is self-assigned, DHCP never answered" ;; *) check_row PASS "ipv4" "${NET_IP}" ;; esac # 4. ip config - a static address is only a problem when its router is # unreachable, which is exactly the "works on the other network" pattern. if [ "$NET_CFG" = "Manual" ] && [ "$GW_OK" -eq 0 ] && [ "$CONN_PROVEN" -eq 0 ]; then check_row FAIL "ip config" "static IP set on ${NET_SVC}, and its router does not answer" elif [ "$NET_CFG" = "Manual" ]; then check_row PASS "ip config" "static IP set on ${NET_SVC} (router answers, so it fits this network)" else check_row PASS "ip config" "$NET_CFG" fi # 5. gateway if [ -z "$NET_GW" ]; then check_row FAIL "gateway" "no default route on ${NET_DEV}" elif [ "$GW_OK" -eq 1 ]; then check_row PASS "gateway" "${NET_GW} answers" elif [ "$CONN_PROVEN" -eq 1 ]; then check_row SKIP "gateway" "${NET_GW} ignores ping, but traffic reaches the internet - ICMP is filtered" else check_row FAIL "gateway" "${NET_GW} does not answer" fi # 6. internet if [ "$WAN_OK" -eq 1 ]; then check_row PASS "internet" "${WAN_VIA} reachable by IP" elif [ "$CONN_PROVEN" -eq 1 ]; then check_row SKIP "internet" "ping is filtered, but DNS and HTTP both succeed" else check_row FAIL "internet" "neither ${PING_IP_A} nor ${PING_IP_B} answers" fi # 7. dns config if [ "$DNS_MANUAL" -eq 1 ] && [ "$DNS_OK" -eq 0 ]; then check_row FAIL "dns config" "manually set on ${NET_SVC}: ${DNS_SERVERS}" elif [ "$DNS_MANUAL" -eq 1 ]; then check_row PASS "dns config" "manually set: ${DNS_SERVERS} (and they work)" else check_row PASS "dns config" "from DHCP: ${DNS_SERVERS:-none reported}" fi # 9. dns resolve if [ "$DNS_OK" -eq 1 ]; then check_row PASS "dns resolve" "${PROBE_HOST} resolves" elif [ "$DNS_PUB_TESTED" -eq 1 ] && [ "$DNS_PUB_OK" -eq 1 ]; then check_row FAIL "dns resolve" "system resolver fails, but ${PUBLIC_DNS} answers directly" else check_row FAIL "dns resolve" "${PROBE_HOST} does not resolve" fi # 9. http if [ "$HTTP_CODE" = "204" ]; then check_row PASS "http" "204 from the captive-portal probe, egress is clean" elif [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" = "000" ]; then check_row FAIL "http" "no response from ${PROBE_URL}" else check_row FAIL "http" "expected 204, got ${HTTP_CODE} - something is intercepting HTTP" fi # 9b. https - the one that actually matters for real browsing. if [ "$HTTPS_CODE" = "204" ]; then check_row PASS "https" "204 over TLS, ordinary browsing should work" elif [ -z "$HTTPS_CODE" ] || [ "$HTTPS_CODE" = "000" ]; then check_row FAIL "https" "no response over TLS while plain HTTP behaved differently" else check_row FAIL "https" "expected 204 over TLS, got ${HTTPS_CODE}" fi # 10. ipv6 - 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 [ -z "$V6_ADDR" ]; then check_row SKIP "ipv6" "no routable IPv6 address, IPv4 only" elif [ "$V6_OK" -eq 1 ]; then check_row PASS "ipv6" "routable and reachable" else check_row FAIL "ipv6" "address ${V6_ADDR} is configured but nothing answers over IPv6" fi # --- sidecar rows: never the primary verdict, but worth seeing ------------ if [ -n "$PROXY_DESC" ]; then check_row SKIP "proxy" "enabled: ${PROXY_DESC}- check it if the layers above all pass" else check_row PASS "proxy" "none set on ${NET_SVC}" fi if [ "${HOSTS_EXTRA:-0}" -gt 0 ]; then check_row SKIP "hosts" "${HOSTS_EXTRA} custom entr(ies) in /etc/hosts" else check_row PASS "hosts" "no custom entries" fi if [ -z "$NET_MTU" ] || [ "$NET_MTU" = "1500" ]; then check_row PASS "mtu" "${NET_MTU:-unknown}" else check_row SKIP "mtu" "${NET_MTU}, not the usual 1500" fi } # --- verdict --------------------------------------------------------------- # One conclusion, aimed at the lowest broken layer, with the command that # addresses that layer. Commands that would overwrite your configuration are # printed only - this script does not run them. verdict() { log "Verdict" case "$FIRST_FAIL" in "") ok "Every layer passes. If pages still fail, the problem is above the network:" note "a single site being down, a browser profile, or an app-level proxy." if [ -n "$PROXY_DESC" ]; then note "A proxy IS configured on ${NET_SVC} (${PROXY_DESC}). That is the first thing to check." fi ;; interface) if [ "$HAS_IF" -eq 0 ]; then note "No Wi-Fi hardware was found on this Mac. That is unusual enough that" note "the rest of the table is meaningless. Confirm what the system sees:" note " networksetup -listallhardwareports" note "If Wi-Fi is missing there, it is turned off in hardware or the service" note "was removed: System Settings > Network." else note "The Wi-Fi interface ${NET_DEV} exists but is not active, so nothing" note "above it can work. The radio is off - that is all this is." note " networksetup -setairportpower ${NET_DEV} on" fi ;; link) note "This Mac is not associated with any Wi-Fi network at all." note "Pick the network in the menu bar first, then re-run this script." ;; ipv4) note "The router never handed this Mac a usable address." note "Worth trying first (note: this de-configures existing IPv4 services" note "on ${NET_DEV} and installs a temporary one, so it is not automated):" note " sudo ipconfig set ${NET_DEV} DHCP" note "" note "If it stays at 169.254.x.x after that, the cause is on the router:" note "a full DHCP pool, or this Mac being blocked by a MAC filter. Other" note "people work because their devices are already known to it. Ask" note "whoever runs the network to let this MAC address in:" note " ifconfig ${NET_DEV} ether" note "" note "Also check Private Wi-Fi Address: when it is on, this Mac shows the" note "router a different MAC on every network, which defeats any binding." note " System Settings > Wi-Fi > (i) next to the current network > Private Wi-Fi Address" ;; "ip config") note "A static IP is configured on ${NET_SVC} and its router does not answer." note "This is the classic leftover: the address belongs to another network," note "which is exactly why this Mac works there and fails here." note "" note "Switching back to automatic will DISCARD that static configuration," note "so this script will not do it for you. Write down the current values" note "first if you need them elsewhere:" note " networksetup -getinfo '${NET_SVC}'" note "Then, when you are sure:" note " sudo networksetup -setdhcp '${NET_SVC}'" ;; gateway) # Two different faults share this row, and they need different advice. if [ -z "$NET_GW" ]; then note "This Mac has an address but no default route on ${NET_DEV} at all." note "DHCP handed out an address and then failed to supply a gateway, so" note "nothing can leave the subnet. Neither a MAC filter nor an IP" note "collision explains this one - the routing table is simply empty." note "" note "See what the interface actually got:" note " netstat -nrf inet | grep -E 'default|${NET_DEV}'" note " ipconfig getpacket ${NET_DEV} # what the DHCP server sent" note "" note "A guest VLAN handing out addresses without a router is the usual" note "cause, and that is the network's problem, not this Mac's." return 0 fi note "This Mac has an address but the router at ${NET_GW} does not answer." note "The Wi-Fi association succeeded, so this is not a password problem." note "Two causes fit \"others are fine, this Mac is not\":" note "" note " 1. The router is filtering this device by MAC address. Show yours:" note " ifconfig ${NET_DEV} ether" note " and have it allowed. If Private Wi-Fi Address is on, turn it off" note " for this network first, otherwise the allowed MAC keeps changing:" note " System Settings > Wi-Fi > (i) next to the current network" note "" note " 2. Another device on the network has taken the same IP. Renewing" note " the lease moves this Mac off the collision (this de-configures" note " existing IPv4 services on ${NET_DEV} first, so run it knowingly):" note " sudo ipconfig set ${NET_DEV} DHCP" ;; internet) note "The router answers but nothing beyond it does. The link is fine and" note "the problem is the network's policy toward this device." note "" note " 1. Captive portal. Hotel, office guest and campus networks hold new" note " devices until they accept a login page. Everyone else got through" note " it already. Force it open in a browser:" note " open http://captive.apple.com/hotspot-detect.html" note "" note " 2. This device is blocked or throttled upstream. Same MAC-address" note " check as above:" note " ifconfig ${NET_DEV} ether" ;; "dns config"|"dns resolve") note "Raw IP addresses work, names do not. This is DNS, and DNS alone." if [ "$DNS_MANUAL" -eq 1 ]; then note "" note "DNS servers are set BY HAND on ${NET_SVC}: ${DNS_SERVERS}" note "Those almost certainly belong to another network - a company or home" note "resolver that simply does not exist here. That single setting explains" note "the whole symptom, including why other networks still work." note "" note "Clearing it hands DNS back to DHCP, and DISCARDS what is set now:" note " sudo networksetup -setdnsservers '${NET_SVC}' Empty" note "Or point it at a public resolver instead:" note " sudo networksetup -setdnsservers '${NET_SVC}' ${PUBLIC_DNS} 1.1.1.1" elif [ "$DNS_PUB_TESTED" -eq 1 ] && [ "$DNS_PUB_OK" -eq 1 ]; then note "" note "${PUBLIC_DNS} answers when asked directly, so the network path is fine." note "The resolvers this network handed out (${DNS_SERVERS:-none}) are not" note "answering. Override them for this service:" note " sudo networksetup -setdnsservers '${NET_SVC}' ${PUBLIC_DNS} 1.1.1.1" note "To go back to whatever DHCP provides:" note " sudo networksetup -setdnsservers '${NET_SVC}' Empty" else note "" note "Start with a cache flush, which --fix does and which changes nothing else:" note " sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder" note "If names still fail afterwards, set a public resolver:" note " sudo networksetup -setdnsservers '${NET_SVC}' ${PUBLIC_DNS} 1.1.1.1" fi ;; ipv6) note "IPv6 is configured on this network but nothing answers over it, while" note "IPv4 is healthy. macOS prefers IPv6, so every connection waits for the" note "v6 attempt to time out first - pages hang, then eventually load." note "" note "This is a fault in the network, not in this Mac, which is why other" note "networks feel fine. Turning IPv6 off for this service is a workaround," note "and it CHANGES a setting, so decide it yourself:" note " sudo networksetup -setv6off '${NET_SVC}'" note "To undo it later:" note " sudo networksetup -setv6automatic '${NET_SVC}'" ;; http) note "Addresses resolve and IPs are reachable, but the HTTP probe did not" note "return the expected 204 - it got: ${HTTP_CODE:-no response}." note "" note "A 200 or a redirect here means something is intercepting HTTP, which" note "is what a captive portal does. Open it and complete the login:" note " open http://captive.apple.com/hotspot-detect.html" if [ -n "$PROXY_DESC" ]; then note "" note "A proxy is also configured on ${NET_SVC} (${PROXY_DESC}). If it points" note "at something that only exists on another network, that fits the" note "symptom too. Inspect it before changing anything:" note " networksetup -getwebproxy '${NET_SVC}'" note " networksetup -getsecurewebproxy '${NET_SVC}'" note " networksetup -getautoproxyurl '${NET_SVC}'" fi ;; https) note "Plain HTTP works but HTTPS does not (${HTTPS_CODE:-no response})." note "Names resolve and port 80 behaves, so this is not a general outage." note "Something is blocking or intercepting TLS on port 443 specifically," note "and since nearly every site is HTTPS, that looks like \"no internet\"." note "" note " 1. A captive portal deliberately lets port 80 through so it can" note " show you its login page, and holds everything else. Open it:" note " open http://captive.apple.com/hotspot-detect.html" note "" note " 2. A TLS-inspecting firewall whose certificate this Mac does not" note " trust - common on corporate and campus networks." note "" note " 3. A proxy that only handles plain HTTP:" note " networksetup -getsecurewebproxy '${NET_SVC}'" ;; *) note "Unexpected first failure: ${FIRST_FAIL}" ;; esac } # --- fix ------------------------------------------------------------------- # sudo_run [args...] - run ONE command privileged, or # print it when we cannot. # # The command is passed as a plain argv, never as a string for a shell to # re-parse. Nothing interpolated here today is attacker-controlled, but the # string form would silently turn any future argument - an SSID, a DNS server, # a gateway - into a root-shell injection with no other code change, and the # fallback below prints the same text for the user to paste into sudo. # # `curl ... | sh` leaves stdin on the pipe, so sudo has no terminal to ask for # a password on; printing the command beats failing silently. sudo_run() { desc="$1" shift if [ "$HUB_DRY_RUN" = "1" ]; then dry "sudo $*" return 0 fi if [ "$(id -u)" = "0" ]; then log "$desc" "$@" || warn "failed: $*" return 0 fi if sudo -n true 2>/dev/null; then log "$desc" sudo "$@" || warn "failed: $*" return 0 fi warn "sudo needs a password and this script has no terminal to ask on." note "Run this one yourself:" note " sudo $*" return 0 } cmd_fix() { log "Fix - the one action that cannot lose anything" note "Flushing the DNS cache is idempotent and overwrites nothing." printf '\n' sudo_run "flushing the DNS cache" dscacheutil -flushcache sudo_run "restarting mDNSResponder so the flush takes effect" \ killall -HUP mDNSResponder printf '\n' # Renewing the lease is deliberately NOT automated here. `ipconfig set # DHCP` reads like a renewal and is quoted as one all over the internet, but # ipconfig(8) is explicit: it de-configures every existing IPv4 service on the # interface first, then installs a new TEMPORARY one. On a machine with more # than one IPv4 service that silently drops the others - which a script # promising to lose nothing has no business doing unattended. note "NOT done automatically - renewing the DHCP lease:" note " sudo ipconfig set ${NET_DEV} DHCP" note "" note "That command is not the harmless renewal it looks like. ipconfig(8):" note " \"Any existing services on the interface for the particular protocol" note " are first de-configured before the new service is instantiated.\"" note "and the service it creates is a temporary one. With a single Wi-Fi" note "service that is fine; if this interface carries others, check them after." printf '\n' note "Re-run without --fix to see whether the table changed." } # --- driver ---------------------------------------------------------------- usage() { cat </dev/null || echo 'macOS')" note "Probing is read-only; this takes a few seconds." printf '\n' probe_all build_rows printf '%s' "$CHECK_ROWS" | awk -F'|' '{ printf "%-5s %-12s %s\n", $1, $2, $3 }' printf '\n' verdict printf '\n' # HUB_DRY_RUN on its own is enough to reach the fix block. Gating this on # --fix alone made the documented preview a dead path: someone who set only # HUB_DRY_RUN got the "re-run with --fix" hint and never saw a single DRY # line, which reads as the flag being broken. if [ "$do_fix" -eq 1 ] || [ "$HUB_DRY_RUN" = "1" ]; then cmd_fix printf '\n' elif [ "$CHECK_FAILS" -gt 0 ]; then note "Re-run with --fix to flush the DNS cache:" note " curl -fsSL ${HUB_BASE}/net.sh | sh -s -- --fix" printf '\n' fi note "Cheatsheet: ${HUB_BASE}/commands/mac/" if [ "$CHECK_FAILS" -gt 0 ]; then return 1 fi return 0 } main "$@"