Claude Skill

net-ops

Cross-platform network troubleshooting (Windows, macOS, Linux) via local or remote shell. Use for: DNS broken, can't resolve hostnames, nslookup/dig works but apps fail, NRPT, WFP, scutil, /etc/resolver, systemd-resolved, resolv.conf, NetworkManager, VPN DNS leak residue (ProtonV

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_net-ops-3dfaf0b.zip · 97 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/net-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Network Operations

Diagnose network problems on Windows, macOS, or Linux with a layered ladder that isolates faults to the smallest possible scope, then pattern-match against OS-specific culprits. Designed for the common case: someone reports "internet broken" on a box you can shell into (locally or via SSH).

The Universal Insight

Bypass-tool succeeds while OS-resolver fails is a smoking gun on every platform. It means DNS infrastructure is healthy but the operating system's name-resolution path is hooked or misconfigured. The bypass tool differs per OS but the discriminator is identical:

OS Bypass tool OS resolver tool If bypass works but resolver fails
Windows nslookup Resolve-DnsName, browsers NRPT, WFP, HOSTS, LSP, local 127.0.0.1:53 proxy
macOS dig @1.1.1.1 dscacheutil -q host, browsers /etc/resolver/*, scutil DNS, profiles, mDNSResponder, kext
Linux dig @1.1.1.1 getent hosts, resolvectl query systemd-resolved, /etc/resolv.conf, NetworkManager, dnsmasq, NSS

The bypass tool implements its own resolver and talks straight to UDP/53. The OS resolver tool goes through the full system name-service path including all hooks. Comparing the two narrows the suspect list dramatically.

The Diagnostic Ladder

Walk down the layers in order. Do not skip rungs. Each rung has a binary outcome that eliminates everything above it. Per-OS tools are in references/diagnostic-ladder.md; the structure is universal.

1. Link layer        — interface up, valid IP, gateway present
2. IP reachability   — ping public IPs over ICMP
3. Socket reach.     — TCP/443 + UDP/53 to known destinations (raw socket DNS)
3.5 LAN services     — parallel track: mapped drives, SMB, mDNS/.local,
                       single-label hostnames (see below — internet OK ≠ LAN OK)
4. DNS infrastructure — bypass tool: nslookup / dig @<server>
5. OS resolver path  — the hook layer (most interesting on modern systems)
6. Application       — real HTTP request to a real hostname

The most common mistake: jumping to rung 6 ("HTTPS doesn't work, must be a cert / proxy") when rung 5 is the actual problem (an orphan VPN DNS rule on Windows, a stale /etc/resolver/ file on macOS, a misconfigured systemd-resolved on Linux). Discipline prevents this.

The LAN Services Track (Rung 3.5)

Rungs 1–6 are framed around reaching the public internet. A mapped drive showing Disconnected while browsing works fine is a different fault domain: local-network service reachability. Symptoms: SMB shares, printers, \\NAS\share, .local names, single-label hostnames.

The load-bearing concept is the single-label hostname resolution path:

HOSTS file → NRPT match → DNS (suffix search) → LLMNR → NetBIOS-NS

An NRPT . catch-all (live VPN or orphan) short-circuits everything below it: the single-label name goes to the VPN resolver (NXDOMAIN) and the LLMNR/NetBIOS broadcast fallback that LAN names normally rely on is suppressed. VPN DNS-leak-protection often also blocks UDP/53 to the LAN router, so there's no fallback resolver either. Full chain, per-OS tools, and the credential-target gotcha: references/diagnostic-ladder.md (rung 3.5).

One-shot audit: scripts/windows/smb-audit.ps1 walks every mapped drive through mapping state → resolution mechanism → ICMP/TCP-445 → credential targeting → NRPT/leak-protection detection, and emits a verdict naming the fix.

VPN + LAN coexistence — decision rule. Is the VPN live and wanted?

  • Yes → do NOT touch the NRPT rule. Pin the name in the HOSTS file (consulted before NRPT, so it wins regardless of VPN state; needs admin), or remap by IP and add a credential keyed to that IP (cmdkey /add:<ip>).
  • No (VPN gone, rule orphaned) → scripts/windows/nrpt-clean.ps1 -Apply.

nrpt-clean.ps1 must never be pointed at a live VPN's catch-all — it exists for orphans only. Deleting a wanted VPN's rule breaks its DNS routing and the client will just re-create it.

Interception-Layer DNS Clients (the adapter-DNS trap)

Modern DNS-filtering clients (NextDNS v3.x, and increasingly others) do not bind port 53 and do not run a loopback proxy. They install a WFP callout driver and rewrite queries to DoH in the kernel. This inverts three habits that are otherwise reliable:

Habit Why it misleads here
Read adapter DNS to learn the resolver Cosmetic. It can show the DHCP router address while every query leaves over DoH.
Get-NetUDPEndpoint -LocalPort 53 to find the proxy Returns nothing for the client. Whoever holds 0.0.0.0:53 (often SharedAccess/ICS) is unrelated.
Query 127.0.0.1 to test the local resolver Times out by design. There is no local resolver.

Ground truth is a query, not a config dump. Ask the provider what it sees:

$r = -join ((1..20) | % { '0123456789abcdefghijklmnopqrstuvwxyz'[(Get-Random -Max 36)] })
(Invoke-WebRequest "https://$r.test.nextdns.io/" -UseBasicParsing).Content

clientName: nextdns-windows means the client owns the query path right now.

The boot-order failure this enables. When the client's profile is stored per-user but its service starts at boot, the service has no profile until the tray hands one over at logon. In that gap DNS resolves via DHCP — often a router running a stricter profile — and Windows caches those answers. Interception then self-corrects; the cache does not. Symptom: ipconfig /flushdns fixes things after every reboot, forever.

That "flush alone fixes it" observation is diagnostic gold — it proves the resolver config is already correct and only the cache is stale, which rules out every reconfiguration-style fix (delayed start, service dependencies, static adapter DNS). Audit with scripts/windows/nextdns-audit.ps1; remedy with scripts/windows/nextdns-boot-fix.ps1 -Apply. Full pattern, rejected alternatives, and the machine-scope DoH option: references/common-culprits.md (W4, W4b).

Workflow

1. Identify the target OS

If local: uname -s (Unix) or check shell environment. If remote over SSH, the bootstrap script auto-detects:

scripts/ssh-bootstrap.sh <user>@<host>

2. Run the OS-appropriate probe

OS Script
Windows scripts/windows/probe.ps1 (via -EncodedCommand over SSH)
macOS scripts/macos/probe.sh
Linux scripts/linux/probe.sh

Each prints structured [PASS]/[FAIL] per rung. Scan for the first FAIL — that's where to drill in.

3. Drill into the failing layer

The interesting failures are almost always rung 5. Per-OS deep-dive scripts:

OS Script What it does
Windows scripts/windows/nrpt-audit.ps1 Dump NRPT rules with attribution + registry forensics
Windows (LAN/SMB) scripts/windows/smb-audit.ps1 Mapped-drive audit: resolution mechanism, reachability, credential targets, NRPT/leak-protection, verdict
Windows (NextDNS) scripts/windows/nextdns-audit.ps1 NextDNS client: config scope, boot→logon exposure window, effective profile, verdict
macOS scripts/macos/dns-audit.sh Dump scutil --dns, /etc/resolver/*, mDNSResponder state, profiles
Linux scripts/linux/dns-audit.sh Dump systemd-resolved status, resolv.conf chain, NM config, NSS order

4. Apply the minimum reversible fix

Repair scripts default to dry-run and protect known-good config (Tailscale MagicDNS, MDM-managed entries). Apply only when the dry-run output matches expectation.

OS Repair script
Windows scripts/windows/nrpt-clean.ps1 (removes orphan NRPT catch-alls, protects Tailscale)
Windows scripts/windows/nextdns-boot-fix.ps1 (logon task: flush once after NextDNS interception is confirmed)
Windows scripts/windows/nextdns-doh-setup.ps1 (machine-scope: point the OS resolver at a profile-pinned DoH template; needs admin)
macOS scripts/macos/resolver-clean.sh (removes orphan /etc/resolver/* from disconnected VPNs)
Linux scripts/linux/resolved-reset.sh (resets systemd-resolved per-link config)

Quick Reference: Smoking Guns

Platform Symptom Most likely cause Quick test
Windows nslookup works, browsers fail Orphan NRPT catch-all (VPN residue) Get-DnsClientNrptRule \| Where Namespace -eq '.'
Windows Public DoH resolver IPs blocked on 443, other 443 works AV "Encrypted DNS Detection" Get-CimInstance -Ns root/SecurityCenter2 -Class AntiVirusProduct
Windows ipconfig /flushdns fixes DNS after every reboot, then it breaks again DNS-filtering client whose profile is per-user while its service starts at boot — the boot→logon gap resolves via the router and Windows caches those answers (NextDNS: W4b) scripts/windows/nextdns-audit.ps1
Windows A DoH client is "started" yet nothing owns :53 and 127.0.0.1 times out Working as designed — modern clients (NextDNS v3.x) intercept via a WFP kernel driver, never bind 53; adapter DNS is cosmetic https://<random>.test.nextdns.io/ → read clientName
macOS dig works, browsers fail Stale /etc/resolver/* from disconnected VPN ls /etc/resolver/ && scutil --dns \| head -40
macOS All DNS fails post-VPN install Configuration profile with DNS override profiles list -type configuration
Linux dig works, getent hosts fails systemd-resolved misconfigured resolvectl status
Linux DNS works on some apps, not others NSS order in /etc/nsswitch.conf excludes resolve grep ^hosts /etc/nsswitch.conf
All DNS suddenly broken after sleep/wake VPN client failed disconnect cleanup OS-specific (see above)
Windows Mapped drive Disconnected, host pings by IP but not by name, VPN active NRPT . catch-all swallowing single-label names (+ suppressing LLMNR/NetBIOS fallback) Get-DnsClientNrptPolicy -Effective \| ? Namespace -eq '.'
Windows net view \\<ip> returns System error 5 while the same share worked by hostname Credential Manager target keyed to hostname, not IP cmdkey /list
All LAN hosts reachable by IP but router's DNS times out on UDP/53 while TCP to it succeeds VPN DNS-leak-protection egress filter scripts/windows/smb-audit.ps1 (LAN DNS EGRESS section)

SSH Transport Patterns

Windows targets

PowerShell-over-SSH has notorious escaping issues. Always pass scripts via -EncodedCommand with UTF-16LE base64:

B64=$(printf '%s' "$PS_SCRIPT" | iconv -t UTF-16LE | base64)
ssh <target> "powershell -NoProfile -EncodedCommand $B64"

Unix targets (macOS, Linux)

Heredoc works cleanly; no special encoding needed:

ssh <target> 'bash -s' < scripts/linux/probe.sh
# or, with arguments:
ssh <target> "bash -s -- arg1 arg2" < scripts/linux/probe.sh

For consistency, scripts/ssh-bootstrap.sh handles both transports based on detected OS.

Pattern Recognition

After a few sessions, certain symptom triplets become instantly diagnosable. See references/case-studies.md for worked examples. Hall-of-fame entries:

Windows: nslookup works, Resolve-DnsName times out identically across all servers, Invoke-WebRequest says "remote name could not be resolved" → orphan NRPT catch-all from a disconnected VPN. Common gateway IP patterns are listed in references/common-culprits.md.

macOS: dig <host> works, browsers say "cannot find server," scutil --dns shows extra "resolver #N" entries pointing at private-range gateways with domain : listed → leftover /etc/resolver/<domain> files from a disconnected VPN.

Linux: dig @<public-resolver> <host> works, getent hosts <host> fails → /etc/nsswitch.conf may have an NSS chain that skips resolve, OR /etc/resolv.conf is no longer symlinked to the systemd-resolved stub.

Safety Notes

  • Read before write. Always dump current state before modifying a resolver config. The forensics may be load-bearing for explaining what happened.
  • Don't disable security tools without consent. AV / firewall hooks are intrusive but legitimate. Pause is preferred over uninstall.
  • Tailscale's name-resolution config looks like junk but is essential. Always filter on protected nameserver patterns (100.100.100.100 on all OSes) before bulk-deleting.
  • Resolver config persists across reboots. Removing a rule is forever (until the VPN re-creates it). Confirm the source/comment before deletion.
  • macOS profile DNS overrides may be MDM-managed. Removing them may violate enterprise policy and may be re-applied automatically. Coordinate with IT.

References

  • references/diagnostic-ladder.md — full ladder methodology with per-OS commands per rung
  • references/common-culprits.md — detection + fix catalog for Windows / macOS / Linux
  • references/case-studies.md — worked examples and template for adding new ones

Scripts

  • scripts/ssh-bootstrap.sh — establish SSH session, auto-detect target OS, emit usable invocation
  • scripts/windows/probe.ps1 — full layered diagnostic for Windows
  • scripts/windows/nrpt-audit.ps1 — NRPT forensics with attribution
  • scripts/windows/nrpt-clean.ps1 — safe NRPT cleanup (orphans ONLY — never point it at a live VPN's catch-all; protects Tailscale)
  • scripts/windows/smb-audit.ps1 — mapped-drive / SMB / LAN-name audit with per-drive verdicts (-DriveLetter Z -FallbackIp 'NAS=192.168.1.50', -Json)
  • scripts/windows/nextdns-audit.ps1 — NextDNS client audit: config scope, boot→logon exposure window, effective profile via test.nextdns.io (-SkipNetwork, -Json)
  • scripts/windows/nextdns-boot-fix.ps1 — installs a per-user logon task that flushes the DNS cache once NextDNS interception is confirmed (dry-run by default; -Apply, -Remove)
  • scripts/windows/nextdns-doh-setup.ps1 — machine-scope alternative: points the Windows DNS Client at a profile-pinned NextDNS DoH template so DNS is correct from boot, disables the conflicting tray client, writes an on-disk breadcrumb, verifies, and rolls back (-Apply, -Rollback, -VerifyOnly; needs admin)
  • scripts/macos/probe.sh — full layered diagnostic for macOS
  • scripts/macos/dns-audit.sh — scutil + /etc/resolver + profile + mDNSResponder dump
  • scripts/macos/resolver-clean.sh — remove orphan /etc/resolver/* files
  • scripts/linux/probe.sh — full layered diagnostic for Linux
  • scripts/linux/dns-audit.sh — systemd-resolved + NM + NSS + resolv.conf dump
  • scripts/linux/resolved-reset.sh — reset systemd-resolved per-link state
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • case-studies.md 14.7 KB
      # Case Studies
      
      Worked examples of network diagnostics that motivated this skill. Each case includes the initial symptoms, the diagnostic path, the dead ends, and the final cause. Identifying details are scrubbed; technical details that reproduce the diagnostic value are preserved.
      
      ## Case 1: The Proton VPN Ghost (Windows)
      
      ### Initial Report
      
      > "Internet not working on my Windows desktop. It wasn't working on wifi earlier today so I switched to ethernet — but problems persisted."
      
      That last sentence is a load-bearing clue: switching physical interface didn't help. That rules out the NIC, driver, cable, and wifi association in a single observation. Whatever is broken lives at the OS layer or above.
      
      ### Diagnostic Path
      
      **Rung 1 (link):** Ethernet `Up`, valid private IP, valid default gateway. ✓
      
      **Rung 2 (ICMP):** Ping `1.1.1.1`, `8.8.8.8`, gateway — all <5ms. ✓
      
      **Rung 3 (sockets):** First test misread — `Resolve-DnsName -Server 1.1.1.1` timed out, which felt like UDP/53 was blocked. **Mistake.** Should have gone straight to raw UDP to disambiguate. When raw UDP/53 was eventually tested, it returned a 124-byte DNS response in milliseconds. Lesson: `Resolve-DnsName` uses the Windows DNS Client API even when `-Server` is specified — it's not a clean probe of the network.
      
      **Rung 3 (sockets, second pass):**
      - TCP/53 to 1.1.1.1 → works
      - Raw UDP/53 to 1.1.1.1 → works (124-byte reply)
      - TCP/443 to 1.1.1.1 → **fails**
      - TCP/443 to 8.8.8.8 → **fails**
      - TCP/443 to 140.82.114.4 (github.com) → works
      - TCP/443 to 13.107.42.14 (microsoft.com) → works
      
      **Discriminator:** Destination-specific HTTPS block. Known public DoH resolver IPs are firewalled on 443; everything else works. **Smell of AV "Encrypted DNS Detection."** Confirmed by `Get-CimInstance -Namespace root/SecurityCenter2`: ESET Security + ESET Firewall both active, and `epfwwfp` WFP callout driver loaded.
      
      This was filed as a **secondary concern** — not the cause of the main symptom (general DNS failure for browsers). Important not to chase the first interesting finding when it doesn't match the headline symptom.
      
      **Rung 4 (nslookup):** `nslookup google.com` against router, 1.1.1.1, and 8.8.8.8 — all returned addresses immediately. ✓
      
      **Rung 5 (DNS Client API):** `Resolve-DnsName google.com -Type A` → timeout. `Invoke-WebRequest https://www.google.com` → "The remote name could not be resolved."
      
      **The smoking gun.** Rung 4 passed perfectly; rung 5 failed identically across all targets. Everything app-level fails because every app uses the DNS Client API. nslookup works because it has its own resolver.
      
      ### The False Lead
      
      First suspicion: ICS. Port 53 was held by `svchost` PID `3928`, which turned out to be the `SharedAccess` service. Stopped it; the service bounced back on a new PID, and DNS resolution did not recover. ICS was a red herring — it was running but its sharing configuration was empty, meaning it wasn't actually doing anything harmful. Lesson: **don't disable a service just because it looks suspicious; verify it's actually causing the symptom first.**
      
      ### The Second False Lead
      
      Next suspicion: ESET's WFP driver. The driver was present and active, and the destination-specific HTTPS block looked like classic AV protocol filtering. But: AV protocol filtering normally affects HTTPS, not DNS Client API calls. Before pausing ESET, ran `Get-DnsClientNrptRule`.
      
      ### The Answer
      
      ```
      Namespace                         NameServers
      ---------                         -----------
      .                                 10.2.0.1
      ```
      
      A catch-all NRPT rule routing every DNS query to `10.2.0.1`. The rule's `Comment` field: **"Force all DNS requests via Proton VPN"** — verbatim from Proton's source code. The IP `10.2.0.1` is Proton's in-tunnel DNS gateway, only reachable while connected to their VPN.
      
      Removed the single rule. Flushed DNS cache. Re-tested:
      - `Resolve-DnsName google.com` → instant success, returned A records
      - `Invoke-WebRequest https://www.google.com` → HTTP 200, full page body
      
      ### Forensics
      
      Checked `C:\Program Files\Proton\VPN\Install.log.txt`: Proton VPN installation confirmed (current Inno Setup log entry showed the latest installed version). Service binaries present (`ProtonVPNService.exe`, `ProtonVPN.WireGuardService.exe`), all in `Stopped` state at time of diagnosis. The last active VPN session timestamp (per `ServiceData\WireGuard\log.bin`) predated the issue report by several days — DNS had been silently broken since the last disconnect, masked by occasional cache hits and apps that handle DNS failure gracefully.
      
      **Likely trigger:** Sleep or hibernate during an active Proton WireGuard session. Proton's disconnect cleanup hook didn't fire, and the NRPT rule outlived the tunnel.
      
      ### Lessons
      
      1. **Always run `Get-DnsClientNrptRule` before suspecting WFP/AV.** It's a one-line check that resolves 90% of "DNS infrastructure works but apps fail" cases.
      2. **Don't conflate `Resolve-DnsName` with a network probe.** It uses the system DNS Client API and inherits every hook in the path. Use raw UDP for actual network-layer DNS testing.
      3. **Multiple anomalies don't mean multiple bugs.** ESET's DoH IP block was a real and separate finding, but it wasn't the cause of the headline symptom. Stay focused on what matches the user's actual complaint.
      4. **The `Comment` field on NRPT rules is gold.** VPN clients tend to write self-identifying strings. Read them before assuming malice.
      5. **Interface-switch ineffective = OS-layer cause.** When wifi → ethernet doesn't fix it, the diagnostic search space contracts dramatically.
      
      ## Case 2: The NAS That Vanished by Name (Windows, live ProtonVPN)
      
      ### Initial Report
      
      > "Mapped drive Z: → \\NAS\vault shows Disconnected." (Windows workstation, 2026-08-06; the NAS is a Synology DiskStation at 192.168.1.50.)
      
      Internet was fine — which is exactly why the existing rungs 1–6 couldn't express the fault: every rung is framed around reaching the public internet. This case created the rung 3.5 LAN-services track.
      
      ### Diagnostic Path
      
      **Rung 3.5 (LAN reachability by IP):** The LAN itself was completely healthy — ICMP to 192.168.1.50 OK, TCP/445 OK, TCP/5000 OK (DSM login page title "NAS - Synology DiskStation"). So the host is up and serving SMB; only the *name* is dead.
      
      **Name resolution:** `Resolve-DnsName NAS` failed with `"The filename, directory name, or volume label syntax is incorrect"` — a misleading error that reads like a typo in the command. `nslookup NAS` gave the honest `Non-existent domain`. (Lesson banked: prefer nslookup for single-label diagnosis.)
      
      **NRPT:** `Get-DnsClientNrptPolicy -Effective` showed a catch-all `Namespace='.'` → NameServers `10.2.0.1` — ProtonVPN's in-tunnel DNS gateway, installed by the live WireGuard session (interface metric lower than Ethernet). The catch-all captures single-label names too, and the NRPT match suppresses the LLMNR/NetBIOS-NS broadcast fallback that bare `NAS` relied on before the VPN.
      
      **Fallback resolver check:** a raw UdpClient DNS query to the LAN router (192.168.1.1) on UDP/53 **timed out**, while ICMP and TCP to the same router succeeded — ProtonVPN's DNS-leak-protection blocks UDP/53 egress to anything but the tunnel resolver. So no fallback resolver existed either.
      
      ### The False Lead
      
      "Just remap by IP": `\\192.168.1.50\vault` returned `System error 5 / Access is denied`. Looks like a permissions problem; isn't. The Credential Manager entry was keyed to target `NAS` (the hostname), and credentials don't apply across target forms. `cmdkey /list` exposed it.
      
      ### The Answer
      
      The VPN was **live and wanted** — so `nrpt-clean.ps1` (built for *orphan* rules from disconnected VPNs) was the wrong tool. The correct fix is coexistence: pin the name in the HOSTS file, which is consulted before NRPT/DNS and therefore wins regardless of VPN state:
      
      ```powershell
      Add-Content $env:windir\System32\drivers\etc\hosts "192.168.1.50  NAS"   # admin
      ```
      
      Drive reconnected by name; existing `NAS`-keyed credential kept working; VPN untouched.
      
      ### Lessons
      
      1. **"Internet OK" proves nothing about LAN names.** Mapped drives / SMB / single-label hostnames are their own track (rung 3.5) with their own resolution chain: HOSTS → NRPT → DNS suffix search → LLMNR → NetBIOS-NS.
      2. **An NRPT `.` catch-all short-circuits everything below it** — including the broadcast fallbacks. And leak protection can remove the LAN router as a fallback resolver too. Two independent mechanisms, one symptom.
      3. **Live vs orphan decides the fix.** Orphan rule → nrpt-clean.ps1. Live wanted VPN → HOSTS pin or IP-remap + matching `cmdkey` target. Never delete a live VPN's catch-all.
      4. **`System error 5` after an IP remap is a credential-target miss, not permissions.** Check `cmdkey /list` before touching share ACLs.
      5. **Don't trust `Resolve-DnsName`'s single-label error text.** `nslookup` tells the truth.
      
      ## Case 3: The Profile That Only Existed After Logon (Windows, NextDNS client)
      
      ### Initial Report
      
      > "On every boot the PC resolves DNS through the ROUTER's NextDNS profile (restrictive — it
      > blocks chatgpt.com) instead of the parent profile served by the NextDNS Windows tray
      > client. The workaround I run after every single reboot is `ipconfig /flushdns`."
      
      Concrete damage: `nslookup chatgpt.com` returned `No such host is known`, which broke
      AI coding CLIs that talk to that host (they could not reach `chatgpt.com/backend-api/...`).
      After the flush they worked immediately.
      
      ### Diagnostic Path
      
      1. **Adapter DNS** — `Ethernet -> 192.168.1.1`, the router, and DHCP-assigned (`Dhcp:
         Enabled`). Looked like the smoking gun. It was not.
      2. **Resolution now (post-flush)** — `chatgpt.com` resolved fine, *while the adapter still
         read 192.168.1.1*. First crack in the theory: if all queries went to the router and the
         router blocked the host, the flush could not have helped.
      3. **NextDNS install state** — `NextDNSService` Running/Automatic; `Get-NetUDPEndpoint
         -LocalPort 53` → `0.0.0.0:53` owned by **svchost**, not NextDNS. Service had **zero** UDP
         endpoints; only an outbound TCP/443 session.
      4. **Port 53 owner** — `tasklist /svc` → `SharedAccess` (ICS, driving the Hyper-V Default
         Switch). Looked like port contention starving a "DNS53 to DoH proxy".
      5. **Ground truth** — `https://<random>.test.nextdns.io/` returned
         `protocol: DOH, clientName: nextdns-windows, destIP: 103.137.14.21` — matching the exact
         TCP/443 peer the service held. The client **was** working. Theory dead.
      6. **Mechanism** — `NextDNSEngine.sys` registered and **Running** as a kernel WFP callout.
         NextDNS never binds 53; it intercepts in-kernel and rewrites to DoH. Adapter DNS is
         therefore cosmetic, and `127.0.0.1:53` timing out is by design.
      7. **Config scope** — `HKLM:\SOFTWARE\NextDNS` absent. The profile ID existed in exactly one
         place: `%LOCALAPPDATA%\NextDNS\...\user.config` (`Configuration`, `Enabled=True`),
         per-user `MachineToLocalUser` scope. Tray autostarts from an **HKLM Run key** = **logon**,
         while the service starts at **boot**.
      
      ### The False Lead
      
      **"Boot-order race against the network stack; set the service to Delayed Start."** The
      supplied hypothesis, and it survived four rungs. It's wrong in *direction*: the service was
      never waiting on link-up, and delaying it would have **lengthened** the broken window.
      
      ### The Second False Lead
      
      **"`SharedAccess` stole port 53 from the DoH proxy."** Extremely convincing — the service is
      literally named *"NextDNS DNS53 to DoH proxy"*, it had no UDP endpoints, and ICS held
      `0.0.0.0:53` (which does mask `127.0.0.1:53`). Killed by a single observation: DoH was
      demonstrably working *anyway*, because v3.x doesn't use the port at all. The service name
      describes an older architecture than the one installed.
      
      ### The Answer
      
      A **config-scope mismatch**, not a race for a resource:
      
      - `NextDNSService` starts at boot as LocalSystem with **no profile ID** — it cannot know one,
        because the ID is stored per-user.
      - The tray hands `Enabled` + `Configuration` over only at **logon**.
      - In that gap nothing intercepts, so DNS goes to DHCP's resolver — the router, running the
        stricter profile — and Windows **caches** its answers, blocks included.
      - Interception then activates correctly. The resolver path self-heals; **the cache does
        not**. Poisoned entries persist until TTL expiry, so `flushdns` "fixes DNS" every time.
      
      Confirmed independently: `https://dns.nextdns.io/<profile-id>` answered `chatgpt.com` with
      `RCODE=0` and 2 answers, so the intended profile never blocked it — the router's did.
      
      **Fix:** a per-user **logon** scheduled task that waits for the NextDNS DoH upstream to be
      established, then flushes once (`nextdns-boot-fix.ps1 -Apply`). Needs no elevation. Delayed
      start, service dependencies, and static adapter DNS were all evaluated and rejected — see
      W4b in `common-culprits.md`.
      
      ### Forensics
      
      Client v3.0.13, binaries dated 2024-03-01, `nextdns.log` 0 bytes (logging off), no
      machine-wide registry key. Nothing was misconfigured by a human: this is the client's stock
      per-user install shape meeting a LAN whose router also filters DNS. The two are individually
      sensible and jointly broken, which is why it reproduced on *every* boot rather than
      intermittently.
      
      ### Lessons
      
      1. **Ask what is answering, not what is configured.** Adapter DNS, NRPT, and port-53
         ownership all lied here. `test.nextdns.io` (`clientName`) settled it in one request.
         Find the equivalent ground-truth probe for whatever owns the query path.
      2. **"Flush alone fixes it" is a *diagnosis*, not a workaround.** It proves the resolver
         config is already correct and only the cache is stale — which immediately eliminates every
         reconfiguration-style fix. Ask what a flush *can't* fix.
      3. **A service name can document a version you don't have.** "DNS53 to DoH proxy" sent the
         investigation after port 53 in a build that intercepts via WFP instead.
      4. **Config *scope* is a boot-order variable.** Per-user config + a machine-scope service =
         a mandatory gap until logon. Check where the config lives before theorising about timers.
      5. **Delaying a service start is almost never the fix for "too early".** Establish what the
         real prerequisite is first; here it was a human logging in.
      
      ## Case 4: Template for Future Entries
      
      When you diagnose a new case worth remembering, add a section here with:
      - Initial report (verbatim if possible)
      - Diagnostic path (rung-by-rung)
      - False leads (the ones you chased before finding the real cause — these are the educational part)
      - The actual cause
      - Forensics (how/when/why it got into that state)
      - Lessons (1-3 reusable observations)
      
      Cases worth adding:
      - A Mullvad-residue case (different IP, otherwise structurally identical to Proton)
      - A corporate AnyConnect leak case
      - A genuine ESET "Encrypted DNS Detection" case where pausing AV was the fix
      - An IPv6-preference-with-broken-v6 slowness case
      - A Winsock LSP corruption case
      
    • common-culprits.md 23.7 KB
      # Common Culprits Catalog
      
      Field guide to known causes of network weirdness on Windows, macOS, and Linux. Ordered within each OS by frequency in observed cases. Each entry: detection command, signature in probe output, and the safe fix.
      
      ---
      
      # WINDOWS
      
      ## W1. Orphaned NRPT Catch-All (VPN residue)
      
      **Frequency:** Very common — most likely cause of "DNS works in nslookup but not browsers."
      
      **Mechanism:** VPN clients (Proton, Mullvad, Cisco AnyConnect, NordVPN, DirectAccess) set an NRPT rule with `Namespace = "."` pointing at their in-tunnel DNS gateway. Buggy disconnect cleanup → rule outlives the tunnel → every DNS query goes into a void.
      
      **Detection:** `Get-DnsClientNrptRule | Where Namespace -eq '.'`
      
      **Telltale IPs:**
      - `10.2.0.x` → Proton VPN
      - `10.64.0.x` → Mullvad
      - `10.211.x.x` → Cisco AnyConnect (varies by enterprise)
      - `10.5.0.x` → NordVPN
      
      **Fix:** `scripts/windows/nrpt-clean.ps1 -Apply` (preserves Tailscale rules). **Only for orphans** — if the VPN is live and wanted, this is the wrong tool; see W1b.
      
      ## W1b. LIVE VPN Catch-All Swallowing LAN Names (ProtonVPN/WireGuard pattern)
      
      **Frequency:** Common on any box that runs a privacy VPN *and* has LAN services (NAS, mapped drives, printers).
      
      **Mechanism:** Same NRPT `Namespace='.'` rule as W1, but the VPN is **connected and doing its job** — internet DNS works fine through the tunnel. The casualty is the LAN: the catch-all captures **single-label** hostnames (`NAS`) too, sends them to the in-tunnel resolver (NXDOMAIN), and the NRPT match suppresses the LLMNR/NetBIOS broadcast fallback those names rely on. ProtonVPN's DNS-leak-protection additionally blocks UDP/53 egress to the LAN router — proven by a raw UdpClient query timing out while ICMP and TCP to the same router succeed — so there is no fallback resolver at all. Mapped drives show `Disconnected`; the host is perfectly reachable by IP (ICMP, TCP/445, TCP/5000 all fine).
      
      **Detection:**
      ```powershell
      Get-DnsClientNrptPolicy -Effective | Where-Object Namespace -eq '.'   # rule present
      Get-NetAdapter | Where-Object Status -eq 'Up'                          # VPN interface Up = LIVE, not orphan
      scripts/windows/smb-audit.ps1                                          # full audit + verdict
      ```
      Use `nslookup NAS` (honest `Non-existent domain`) rather than `Resolve-DnsName NAS`, whose single-label failure text — `"The filename, directory name, or volume label syntax is incorrect"` — is misleading.
      
      **Fix (coexistence — do NOT run nrpt-clean.ps1 on a live VPN's rule):**
      1. Pin the name in HOSTS (admin): `Add-Content $env:windir\System32\drivers\etc\hosts "192.168.1.50  NAS"` — HOSTS is consulted before NRPT/DNS, so it wins regardless of VPN state.
      2. Or remap by IP **plus** a credential keyed to the IP: `cmdkey /add:192.168.1.50 /user:<user> /pass` — without the cmdkey step you hit W1c.
      
      ## W1c. Credential Manager Target Keyed to Hostname, Not IP
      
      **Frequency:** Bites exactly when working around W1b by remapping to the raw IP.
      
      **Mechanism:** Credential Manager keys stored credentials on the **target string**. A credential stored for target `NAS` does not apply to `\\192.168.1.50\vault`, so the IP-based remap returns `System error 5 / Access is denied` — which looks like a share-permission problem but is a credential-lookup miss.
      
      **Detection:** `cmdkey /list` — compare the `Target:` entries against the host form actually in the UNC path (hostname vs IP).
      
      **Fix:** `cmdkey /add:<ip> /user:<user> /pass:<pw>`, or keep using the hostname and fix resolution instead (W1b's HOSTS pin keeps the existing credential valid).
      
      ## W2. AV WFP Hooks (ESET / Kaspersky / Bitdefender / Norton)
      
      **Frequency:** Common on machines with full security suites.
      
      **Mechanism:** AV products install Windows Filtering Platform callout drivers. Features like "Encrypted DNS Detection," "SSL/TLS Protocol Filtering," "Web Access Protection" can block public DoH resolver IPs on 443 and intercept DNS Client API calls.
      
      **Detection:** `Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct` and `Get-CimInstance Win32_SystemDriver | Where Name -match 'epfwwfp|wfpcap|symefa|mfewfpk|bdfwfpf'`
      
      **Signature in probe:** `TCP/443 -> 1.1.1.1` FAIL but `TCP/443 -> 140.82.114.4` (github.com) PASS.
      
      **Fix:** Pause AV via tray → re-probe → if confirmed, disable "Encrypted DNS Detection" in AV settings or add the public resolver IPs to allowed addresses.
      
      ## W3. Internet Connection Sharing (ICS) Stuck
      
      **Frequency:** Occasional, often a side effect of Mobile Hotspot or Hyper-V.
      
      **Mechanism:** `SharedAccess` service binds DNS proxy on `0.0.0.0:53`. Usually a red herring — its presence doesn't cause failures alone, but it can mask the underlying culprit.
      
      **Detection:** `Get-Service SharedAccess` + `Get-NetUDPEndpoint -LocalPort 53`
      
      **Fix (if confirmed unwanted):** `Set-Service SharedAccess -StartupType Disabled; Stop-Service SharedAccess -Force`
      
      ## W4. Local 127.0.0.1:53 Proxy (AdGuard / Pi-hole client / Cloudflare WARP)
      
      **Frequency:** Increasing as DoH-via-proxy adoption grows.
      
      **Detection:** `Get-NetUDPEndpoint -LocalPort 53 | Where LocalAddress -eq '127.0.0.1'`
      
      **Fix:** Restart the proxy service, or temporarily reconfigure DNS to a public resolver and verify:
      ```powershell
      Set-DnsClientServerAddress -InterfaceAlias Ethernet -ServerAddresses 1.1.1.1,8.8.8.8
      ```
      
      **NOT NextDNS (v3.x).** This entry used to list NextDNS and that was wrong — it cost an
      investigation an hour. The NextDNS Windows client v3.x does **not** bind port 53 and does
      **not** run a loopback proxy. It intercepts DNS in the kernel with a WFP callout driver
      (`NextDNSEngine.sys`). Consequences, all counter-intuitive:
      
      - `Get-NetUDPEndpoint -LocalPort 53` shows **nothing** owned by NextDNS. Its service has no
        UDP endpoints at all — only an outbound TCP/443 session to its DoH upstream.
      - Querying `127.0.0.1` **times out by design.** That timeout is not the fault.
      - Whoever *does* hold `0.0.0.0:53` (usually `SharedAccess`/ICS for the Hyper-V Default
        Switch — see W3) is **unrelated**. Do not go after it.
      - Adapter DNS is **cosmetic** while interception is live: `Get-DnsClientServerAddress` can
        read the DHCP router address while every query leaves over DoH.
      
      Ground truth for "who is actually answering" is never the adapter config — it is
      `https://<random>.test.nextdns.io/`, which returns `protocol`, `profile`, and `clientName`.
      `clientName: nextdns-windows` means the client owns the query path right now.
      
      ## W4b. NextDNS Boot-Order Profile Inheritance (per-user config vs boot-time service)
      
      **Frequency:** Every boot, on any box where the NextDNS Windows client is configured
      per-user and the LAN router runs its own (stricter) NextDNS profile.
      
      **Symptom:** After every reboot the PC resolves through the **router's** NextDNS profile
      instead of its own, so whatever that profile blocks is broken — classically AI endpoints
      (`chatgpt.com` → `No such host is known`, taking any AI coding CLI with it). A bare
      `ipconfig /flushdns` fixes it completely, and it comes back on the next boot.
      
      **Mechanism — a config *scope* mismatch, not a network race:**
      
      | When | What happens |
      |---|---|
      | Boot | `NextDNSService` starts (Automatic, LocalSystem) with **no profile ID** |
      | Boot → logon | Nothing intercepts. DNS goes to the DHCP resolver = **the router** = its stricter profile. Blocks/NXDOMAIN get **cached** |
      | Logon | `NextDNS.exe` (tray) starts from a Run key and hands `Enabled` + `Configuration` to the service |
      | After | Interception is correct — but the **poisoned cache entries survive their TTL** |
      
      The profile ID lives **only** in the per-user tray config
      (`%LOCALAPPDATA%\NextDNS\NextDNS.exe_Url_*\*\user.config`, .NET `MachineToLocalUser`
      scope). There is no `HKLM:\SOFTWARE\NextDNS`. A LocalSystem service starting at boot
      therefore cannot know which profile to use until a user logs in.
      
      **The discriminator that tells you which fault you have.** Once resolution is failing, ask
      whether the resolver path is wrong *or* merely the cache is stale:
      
      ```powershell
      $r = -join ((1..20) | % { '0123456789abcdefghijklmnopqrstuvwxyz'[(Get-Random -Max 36)] })
      (Invoke-WebRequest "https://$r.test.nextdns.io/" -UseBasicParsing).Content
      ```
      
      - `clientName: nextdns-windows` → config is **already correct**; you have a **stale cache**.
        Flush. Do **not** reconfigure anything.
      - anything else → interception genuinely inactive; the router profile is live.
      
      **Detection (one shot):** `scripts/windows/nextdns-audit.ps1` — reports install state,
      config scope, the measured boot→tray exposure window, and the effective profile, with a
      verdict. Exit 10 when the exposure pattern is present.
      
      **Fix:** `scripts/windows/nextdns-boot-fix.ps1 -Apply` — installs a per-user **logon**
      scheduled task that waits until the NextDNS DoH upstream is established, then flushes the
      cache once. No elevation needed (`Clear-DnsClientCache` and `ipconfig /flushdns` both work
      unelevated). Because the surviving fault is a stale cache, invalidating it once after
      interception is up is the whole remedy.
      
      **Fixes that do NOT work, and why — check these off before proposing them:**
      
      | Candidate | Verdict |
      |---|---|
      | `NextDNSService` → Automatic (Delayed Start) | **Backwards.** The race is against *user logon*, not link-up. Delaying the service lengthens the unprotected window. |
      | Add a service dependency on the network stack | Irrelevant — the missing prerequisite is the tray handing over a profile, not the network being up. |
      | Pin static adapter DNS at the client's resolver | There **is** no local resolver (see W4). Adapter DNS is cosmetic under WFP interception. |
      | Chase whoever holds `0.0.0.0:53` | Unrelated (W3). |
      | Change the router's profile | Out of scope when the router config is deliberate — the bug is this PC inheriting it. |
      
      **Machine-scope alternative (removes the window instead of cleaning up after it).**
      Needs admin, and is the better architecture if you want the boot window itself to be
      correct: point the OS resolver at the profile-specific NextDNS DoH endpoint, which is
      machine scope and applies before any logon.
      
      ```powershell
      # Profile-pinned DoH template — the profile is carried in the URL path, so this needs
      # no "Linked IP" and survives a dynamic WAN address.
      Add-DnsClientDohServerAddress -ServerAddress 45.90.28.0 `
          -DohTemplate 'https://dns.nextdns.io/<profile-id>' -AllowFallbackToUdp $false -AutoUpgrade $true
      Set-DnsClientServerAddress -InterfaceAlias Ethernet -ServerAddresses 45.90.28.0
      ```
      
      Verify the endpoint before committing to it — an RFC 8484 GET returning `RCODE=0` proves
      the profile answers:
      
      ```powershell
      # base64url-encoded DNS query for one A record; expect HTTP 200 and RCODE 0
      Invoke-WebRequest "https://dns.nextdns.io/<profile-id>?dns=<b64url>" -Headers @{Accept='application/dns-message'}
      ```
      
      **Automated:** `scripts/windows/nextdns-doh-setup.ps1 -ProfileId <id> -Apply` (elevated) does
      all of the above, disables the conflicting tray client, writes a breadcrumb README to
      `%LOCALAPPDATA%\net-ops\`, verifies via `test.nextdns.io`, and supports `-Rollback -Apply`.
      
      **The silent-failure mode you must design around.** Measured 2026-08-24, same names queried
      three ways over DoH:
      
      | name | `/<profile>` | no path (Linked-IP) | bogus profile |
      |---|---|---|---|
      | `doubleclick.net` | 1 answer | 6 answers | 6 answers |
      | `google-analytics.com` | 1 answer | 6 answers | 6 answers |
      
      The path changes the answer — that *is* the profile selection. But a **bogus profile ID did
      not error**; it returned unfiltered results identical to no profile at all. So a typo, or a
      fallback to plain DNS against the anycast IP, yields working-but-unfiltered DNS that
      announces nothing. Two mitigations, both mandatory: set `-AllowFallbackToUdp $false` so a DoH
      failure breaks loudly, and **verify after applying** rather than assuming.
      
      Caveats worth knowing before you reach for this:
      - **Plain UDP/53 to a NextDNS anycast IP does *not* pin your profile** — unauthenticated
        anycast relies on "Linked IP", which breaks on a dynamic WAN address. Only the DoH
        template's URL path pins it deterministically.
      - Profile-encoded NextDNS **IPv6** (`2a07:a8c0::<id>`) is useless on an IPv4-only egress —
        test reachability first rather than assuming.
      - Running this **and** the NextDNS client together means two things claim the DNS path.
        Pick one: if you go machine-scope DoH, disable the client's interception.
      - Many routers drop outbound UDP/53 to third-party resolvers while leaving 443 alone, so
        DoH is often the *only* path that works anyway — verify with `TcpClient` on 443.
      
      ## W5. Consumer Router DoH IP Blocking (also affects macOS, Linux)
      
      **Frequency:** Growing rapidly. Most prosumer routers from 2023+ ship with this enabled by default.
      
      **Mechanism:** Consumer routers with "parental controls" / "safe browsing" / "advanced threat protection" features maintain blocklists of known public DoH (DNS-over-HTTPS) resolver IPs and silently drop TCP/443 to them. Goal: prevent DoH from bypassing the router's DNS filtering. Affects:
      
      - **Asus AiProtection / Trend Micro filtering**
      - **TP-Link HomeShield / HomeCare**
      - **Eero Secure / Secure+**
      - **Netgear Armor**
      - **Synology Safe Access**
      - **OPNsense/pfSense with custom blocklists**
      - **Pi-hole upstream config blocking DoH**
      
      **Detection (cross-platform — this is a network-level block, not OS-specific):**
      - `TCP/443 -> 1.1.1.1` FAIL **and** `TCP/443 -> 8.8.8.8` FAIL **and** `TCP/443 -> 9.9.9.9` FAIL
      - `TCP/443 -> <github.com IP>` PASS (control: any non-DoH 443 destination)
      - Failure pattern is **identical across multiple devices on the same LAN**
      
      Confirmed via this skill's dogfooding: a single Asus AiProtection-enabled LAN blocked 1.1.1.1:443 and 8.8.8.8:443 from both a Windows desktop and a macOS laptop, while github.com:443 worked from both. The discriminator (different destinations, same port) immediately localized the block to the router/LAN rather than per-device AV.
      
      **Fix:**
      1. Router admin UI → disable parental controls / safe browsing / threat protection for the affected client, OR for the entire LAN
      2. If you need DoH but can't change router config: use Cloudflare's `1.1.1.1` on port 853 (DoT, often unblocked) or via WARP client which uses non-standard ports
      3. Many routers allow per-device exemption — exclude the diagnostic machine if you don't want to disable network-wide
      
      **Important:** This is not a malicious block. It's a working-as-intended security feature, often beneficial. Only override if you have a specific reason to bypass it (e.g., legitimate use of DoH for privacy).
      
      ## W6. HOSTS File Pollution / Winsock LSP Corruption
      
      **Frequency:** Rare but quick to check / nuclear to fix.
      
      **Detection:** `Get-Content $env:windir\System32\drivers\etc\hosts` + `netsh winsock show catalog`
      
      **Nuclear fix (requires reboot):** `netsh winsock reset; netsh int ip reset`
      
      ---
      
      # macOS
      
      ## M1. Orphan `/etc/resolver/<domain>` Files (VPN residue)
      
      **Frequency:** Very common — macOS equivalent of the Windows NRPT bug.
      
      **Mechanism:** Some VPN clients (especially Cisco AnyConnect / Secure Client, Proton VPN, occasional Mullvad) write per-domain resolver files to `/etc/resolver/`. Each file points a specific DNS suffix at the VPN gateway. On disconnect, cleanup is supposed to remove them. It often doesn't.
      
      **Detection:**
      ```bash
      ls /etc/resolver/
      scutil --dns | head -40
      ```
      
      **Telltale IPs in `/etc/resolver/<file>`:**
      - `10.2.0.x` → Proton VPN
      - `10.64.0.x` → Mullvad
      - `10.211.x.x` → Cisco AnyConnect
      - `127.0.0.1` → local DNS proxy (NextDNS, AdGuard)
      
      **Signature:** `dig @1.1.1.1 google.com` works but `dscacheutil -q host -a name google.com` fails OR returns wrong addresses. `scutil --dns` shows extra "resolver #N" entries with `domain :` lines naming corporate or VPN-specific zones.
      
      **Fix:** `scripts/macos/resolver-clean.sh --apply` (protects Tailscale's MagicDNS).
      
      ## M2. Configuration Profile DNS Override (MDM-installed)
      
      **Frequency:** Common on managed Macs.
      
      **Mechanism:** MDM (Jamf, Intune, Kandji, Mosyle) can push DNS configuration via a `.mobileconfig` profile that overrides the resolver chain. If the profile points at an internal corporate DNS that's unreachable from your current network, all resolution dies.
      
      **Detection:**
      ```bash
      profiles list -type configuration
      sudo profiles show -type configuration   # full payloads
      ```
      
      **Fix:** Coordinate with IT — removing an MDM-managed profile may violate policy and may be re-applied automatically. The fix usually involves connecting to corporate VPN to make the internal DNS reachable, or asking IT to amend the profile.
      
      ## M3. mDNSResponder Crashed / Hung
      
      **Frequency:** Rare but catastrophic when it happens.
      
      **Detection:** `pgrep -x mDNSResponder`
      
      **Fix:**
      ```bash
      sudo killall -HUP mDNSResponder     # gentle nudge
      sudo killall mDNSResponder          # force restart (launchd auto-respawns)
      sudo dscacheutil -flushcache
      ```
      
      ## M4. Stale `scutil --dns` State After Network Change
      
      **Frequency:** Occasional, especially after sleep/wake or switching wifi networks.
      
      **Mechanism:** macOS's resolver cache can hang on to settings from a previous network. New connection has fresh DNS servers but the resolver chain still has entries from the previous network.
      
      **Detection:** `scutil --dns` shows multiple resolvers with different IPs that don't match the current network's actual DNS.
      
      **Fix:**
      ```bash
      sudo killall -HUP mDNSResponder
      sudo dscacheutil -flushcache
      ```
      
      If persistent: cycle the active network interface in System Settings → Network → Details → Renew DHCP Lease.
      
      ## M5. Third-Party Network Kext / System Extension
      
      **Frequency:** Decreasing (Apple has deprecated kexts in favor of system extensions).
      
      **Mechanism:** Cisco AnyConnect, Little Snitch, Lulu, some legacy AV products. Can hook the network stack at kernel level.
      
      **Detection:** `kextstat | grep -iE 'cisco|anyconnect|proton|mullvad|nord|littlesnitch|lulu'`
      
      **Fix:** Disable via the app's GUI, not by force-unloading the kext.
      
      ## M6. PAC File / Proxy Set System-Wide
      
      **Frequency:** Common in corporate environments.
      
      **Detection:** `scutil --proxy`
      
      **Fix:** System Settings → Network → Details → Proxies → toggle off the relevant proxy (or check that the PAC URL is reachable).
      
      ---
      
      # LINUX
      
      ## L1. `/etc/resolv.conf` No Longer Symlinked to systemd-resolved Stub
      
      **Frequency:** Common — happens when VPN clients or DHCP scripts overwrite the symlink.
      
      **Mechanism:** systemd-resolved expects `/etc/resolv.conf` to be a symlink to `/run/systemd/resolve/stub-resolv.conf`. If a VPN script (or an Old-School Sysadmin) replaces it with a plain file containing static nameservers, the file becomes a stale snapshot — apps using libc's resolver hit the static file while `resolvectl` operates independently.
      
      **Detection:**
      ```bash
      readlink /etc/resolv.conf
      # Expected on systemd-resolved hosts:
      # /run/systemd/resolve/stub-resolv.conf
      # Or:
      # ../run/systemd/resolve/stub-resolv.conf
      ```
      
      **Fix:**
      ```bash
      sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
      sudo systemctl restart systemd-resolved
      ```
      
      ## L2. systemd-resolved Per-Link DNS Stuck After VPN Disconnect
      
      **Frequency:** Very common — Linux equivalent of the Windows NRPT bug.
      
      **Mechanism:** VPN clients (OpenVPN, WireGuard, Mullvad, Proton CLI) push per-link DNS via `resolvectl dns <iface> <servers>` when connecting. Cleanup on disconnect should revert the link's DNS, but many scripts forget. Result: queries route to a dead per-link DNS server.
      
      **Detection:** `resolvectl status` shows DNS servers configured on a VPN interface that's no longer routing, OR a global fallback that no longer applies.
      
      **Fix:** `scripts/linux/resolved-reset.sh --apply`
      
      ## L3. `/etc/nsswitch.conf` hosts Line Excludes Resolver
      
      **Frequency:** Rare but devastating.
      
      **Mechanism:** If `/etc/nsswitch.conf` has `hosts: files dns` on a systemd-resolved system, glibc bypasses `resolve` (the systemd-resolved NSS module) and goes straight to whatever `/etc/resolv.conf` says. If that's broken, all libc-based name resolution fails — even though `resolvectl query` may still work.
      
      **Detection:**
      ```bash
      grep "^hosts:" /etc/nsswitch.conf
      # Healthy on systemd-resolved system:
      # hosts: files mymachines resolve [!UNAVAIL=return] dns myhostname
      ```
      
      **Fix:** Restore the canonical line per your distro's defaults. On Ubuntu/Debian:
      ```bash
      sudo sed -i 's/^hosts:.*/hosts: files mymachines resolve [!UNAVAIL=return] dns myhostname/' /etc/nsswitch.conf
      ```
      
      ## L4. NetworkManager `dns=` Mode Conflicts With systemd-resolved
      
      **Frequency:** Occasional on desktop Linux.
      
      **Mechanism:** NetworkManager has its own opinions about DNS. Settings include `none` (NM doesn't touch DNS), `dnsmasq` (NM starts a local dnsmasq), `systemd-resolved` (NM hands off to systemd-resolved). Mismatch between NM's mode and what's actually running creates a fight.
      
      **Detection:**
      ```bash
      awk '/\[main\]/,/\[/{if(/^dns/)print}' /etc/NetworkManager/NetworkManager.conf
      ls /etc/NetworkManager/conf.d/
      ```
      
      **Fix:** Pick one strategy and stick with it. The modern recommended setup is `dns=systemd-resolved` on systemd distros.
      
      ## L5. dnsmasq Local Instance Bound to 127.0.0.1:53
      
      **Frequency:** Occasional, especially with old NetworkManager configs or libvirt installs.
      
      **Mechanism:** A local dnsmasq listens on 127.0.0.1:53 and `/etc/resolv.conf` points at 127.0.0.1. If dnsmasq's upstream config is broken or stale, all DNS fails despite the infrastructure being fine.
      
      **Detection:** `ss -tulnp | grep ':53'`
      
      **Fix:** Check dnsmasq's actual upstream config (`/etc/dnsmasq.d/*`, `/etc/NetworkManager/dnsmasq.d/*`) and restart: `sudo systemctl restart dnsmasq` or `sudo systemctl restart NetworkManager`.
      
      ## L6. WireGuard / OpenVPN PostUp DNS Hook Failure
      
      **Frequency:** Common with hand-rolled VPN configs.
      
      **Mechanism:** WireGuard configs often have `PostUp = resolvectl dns %i 10.0.0.1` and `PostDown = resolvectl revert %i`. If `wg-quick down` is killed before `PostDown` runs (sleep, SIGKILL, crash), the DNS state is never reverted.
      
      **Detection:** `resolvectl status` shows DNS on a `wg*` interface that no longer exists, or `ip link` shows no `wg*` interface but `resolvectl` still has DNS configured for one.
      
      **Fix:** `scripts/linux/resolved-reset.sh --apply` cleans most of this. For lingering interface entries: `sudo resolvectl revert <ifname>`.
      
      ## L7. Container / WSL2 Special Cases
      
      **Frequency:** Occasional.
      
      **Mechanism:**
      - **Docker containers** inherit DNS from the host. If host DNS is broken, containers inherit the breakage. Containers using `--network=host` follow host config exactly.
      - **WSL2** has its own resolver chain. `/etc/resolv.conf` inside WSL2 is auto-generated by `wsl.conf`. Windows-side DNS hooks (NRPT, AV) don't affect WSL2 unless `wsl.conf` is configured to share them.
      
      **Detection:**
      - Docker: `docker exec <container> cat /etc/resolv.conf`
      - WSL2: `cat /etc/resolv.conf` inside WSL + `cat /etc/wsl.conf`
      
      **Fix:**
      - Docker: fix host DNS first, then `docker restart`
      - WSL2: configure `/etc/wsl.conf` with `[network]\ngenerateResolvConf = false` and write a custom `/etc/resolv.conf`
      
      ---
      
      ## Cross-OS Process-of-Elimination Summary
      
      ```
      Apps fail, bypass tool (nslookup / dig) works
          ↓
      Check OS-specific catch-all DNS hook:
          Windows → Get-DnsClientNrptRule | Where Namespace -eq '.'
          macOS   → ls /etc/resolver/ + scutil --dns
          Linux   → resolvectl status (per-link DNS) + readlink /etc/resolv.conf
          ↓ clean
      Check HOSTS / nsswitch:
          Windows → C:\Windows\System32\drivers\etc\hosts
          macOS   → /etc/hosts
          Linux   → /etc/hosts + /etc/nsswitch.conf hosts line
          ↓ clean
      Check local 127.0.0.x:53 listener (DNS proxy):
          Windows → Get-NetUDPEndpoint -LocalPort 53
          macOS   → lsof -i UDP:53
          Linux   → ss -tulnp | grep :53
          ↓ clean
      Check security software / kernel hooks:
          Windows → WFP drivers (epfwwfp et al.)
          macOS   → kextstat / system extensions
          Linux   → iptables -L OUTPUT / nft list ruleset
          ↓ clean
      Welcome to the long tail — start reading per-OS resolver logs.
      ```
      
    • diagnostic-ladder.md 12.3 KB
      # The Diagnostic Ladder
      
      A layered methodology for isolating network faults from the wire up, applicable to Windows, macOS, and Linux. Each rung has a binary outcome that eliminates everything above it — walk in order, do not skip.
      
      ## Why Layered Probing Beats Pattern Matching
      
      When a user says "internet is broken," there are roughly 30 plausible causes spanning seven OSI-ish layers. Guessing wastes time. The ladder is a binary-search through the stack: each test eliminates roughly half the remaining suspects.
      
      The most common mistake is jumping straight to layer 6 ("HTTPS doesn't work, must be a cert / proxy / SNI thing") when the real issue is layer 5 (the OS resolver is being hijacked by an orphaned VPN config from a tunnel that hasn't been connected in four days). Discipline prevents this.
      
      ## Per-OS Tool Reference
      
      | Rung | Windows | macOS | Linux |
      |---|---|---|---|
      | 1. Link | `Get-NetAdapter` / `Get-NetIPConfiguration` | `ifconfig` / `networksetup -listallhardwareports` | `ip -br link` / `ip -br addr` |
      | 2. ICMP | `Test-Connection 1.1.1.1` | `ping -c 2 1.1.1.1` | `ping -c 2 1.1.1.1` |
      | 3. TCP/UDP | `Test-NetConnection -Port 443` + raw UDP via .NET | `nc -zv` + `dig @<ip>` | `bash </dev/tcp/<ip>/443` + `dig @<ip>` |
      | 4. DNS infra | `nslookup google.com 1.1.1.1` | `dig @1.1.1.1 google.com` | `dig @1.1.1.1 google.com` |
      | 5. OS resolver | `Resolve-DnsName` | `dscacheutil -q host -a name google.com` | `getent hosts google.com` / `resolvectl query` |
      | 6. App layer | `Invoke-WebRequest` | `curl -v` | `curl -v` |
      
      ## Rung 1 — Link Layer
      
      **Question:** Is there a physical / wireless connection with a valid IP and gateway?
      
      **Pass criteria:** At least one adapter `Up` / `active` / `UP`, has an IPv4 address, has a default gateway.
      
      **Fail → check:** Driver state, cable, wifi association, DHCP lease, static config typo.
      
      **Common gotchas across all OSes:**
      - A `169.254.x.x` address (Windows/Linux) or `self-assigned` (macOS) means DHCP failed silently
      - Multiple `Up` adapters can have competing default routes; check route metric / priority
      
      ## Rung 2 — IP / ICMP Reachability
      
      **Question:** Can packets leave the box and reach the public internet?
      
      **Pass criteria:** Replies in single-digit to low-double-digit milliseconds for at least one public anycast IP.
      
      **Fail → check:** Routing table, firewall rules blocking ICMP outbound, ISP outage, captive portal.
      
      **Watch for:** Some ISPs and corporate firewalls block ICMP entirely while allowing TCP/UDP. If ICMP fails but TCP socket tests pass on rung 3, ICMP is the *only* thing blocked — rare but real, especially on enterprise networks.
      
      ## Rung 3 — TCP/UDP Socket Reachability
      
      **Question:** Can specific transport-layer connections complete?
      
      **Critical discriminator:** Test multiple destinations on the same port. If `1.1.1.1:443` fails but `140.82.114.4:443` (github.com) succeeds, the block is **destination-specific**, not a general firewall. Strongly suggests AV with "Encrypted DNS Detection" or per-IP blocklist.
      
      **Raw UDP/53 test is essential.** Most OS-level DNS probes (`Resolve-DnsName`, `dscacheutil`, `getent`) go through the system resolver and inherit every hook in the path. To test UDP/53 itself, use:
      - Windows: `dig` (if installed) or a custom `UdpClient` (see `probe.ps1`)
      - macOS / Linux: `dig +tries=1 @<server> <host>`
      
      `dig` explicitly bypasses the OS resolver chain. This is what makes it the killer discriminator on Unix systems — same role as `nslookup` on Windows.
      
      ## Rung 3.5 — LAN Services (parallel track)
      
      **Question:** Can this box reach services on its OWN network, by name? Mapped drives, SMB shares, printers, NAS admin pages, `.local` names, single-label hostnames.
      
      Rungs 1–6 are framed around the public internet, and a box can pass all of them while every LAN name is dead. The reverse also holds: a `Disconnected` mapped drive proves nothing about internet health. Treat LAN services as a parallel track entered whenever the complaint names a share, a NAS, a printer, or a `\\hostname` path.
      
      **Windows one-shot:** `scripts/windows/smb-audit.ps1` runs this whole rung per mapped drive.
      
      ### The single-label hostname resolution path (Windows)
      
      A name like `NAS` (no dots) is resolved through a chain most people never see:
      
      ```
      1. HOSTS file          %windir%\System32\drivers\etc\hosts — always consulted FIRST
      2. NRPT match          policy table; a Namespace='.' rule captures EVERYTHING —
                             including single-label names — and pins the nameserver
      3. DNS + suffix search single-label names get the connection-specific suffix appended
      4. LLMNR               multicast to the local subnet (UDP/5355)
      5. NetBIOS-NS          broadcast (UDP/137) — the legacy path that makes bare
                             \\NAS work on flat home LANs
      ```
      
      **The load-bearing insight: an NRPT `.` catch-all short-circuits everything below it.** Once a name matches an NRPT rule, the query goes to that rule's nameserver and *the LLMNR/NetBIOS broadcast fallback is suppressed* — Windows considers the name "handled." So a live VPN with a catch-all (ProtonVPN, Mullvad, corporate DirectAccess) sends `NAS` to the tunnel resolver, gets NXDOMAIN, and never falls back to the broadcast mechanisms that made the name work before the VPN. The share dies by name while the host answers by IP.
      
      Check the *effective* policy, not just configured rules:
      
      ```powershell
      Get-DnsClientNrptPolicy -Effective | Where-Object Namespace -eq '.'
      ```
      
      **Tool honesty note:** on a single-label name that fails, `Resolve-DnsName NAS` throws the misleading `"The filename, directory name, or volume label syntax is incorrect"` — it looks like you typed the command wrong. `nslookup NAS` gives the honest `Non-existent domain`. **Prefer `nslookup` for single-label diagnosis by hand.** (Scripted, `Resolve-DnsName -DnsOnly` / `-LlmnrNetbiosOnly` are still useful because the switches isolate individual mechanisms — just don't trust the error text.)
      
      ### VPN DNS-leak-protection: no fallback resolver either
      
      Privacy VPNs (ProtonVPN confirmed; Mullvad similar) don't just install the NRPT rule — their leak protection **blocks UDP/53 egress to anything but the tunnel resolver**, including the LAN router. Signature: raw UDP/53 DNS query to the default gateway times out while ICMP and TCP to the same gateway succeed. This means pointing an interface at the router's DNS won't help while the VPN is up.
      
      ### The credential-target-keying trap
      
      Windows Credential Manager keys stored credentials on the **target string**. A credential stored for target `NAS` does not apply to `\\192.168.1.50\vault` — so the obvious workaround "just remap by IP" fails with `System error 5 / Access is denied`, which reads as a permissions problem but isn't. Check with `cmdkey /list`; fix with `cmdkey /add:192.168.1.50 /user:<user> /pass:<pw>` (or pin the hostname in HOSTS and keep using the name, which also keeps the existing credential valid).
      
      ### Fix decision rule (VPN + LAN coexistence)
      
      | VPN state | Correct fix |
      |---|---|
      | **Live and wanted** | Coexistence, not cleanup: pin the name in HOSTS (`<ip>  <name>`, admin required — HOSTS is consulted before NRPT so it wins in every VPN state), OR remap by IP + `cmdkey /add:<ip>` |
      | **Disconnected / orphaned rule** | `scripts/windows/nrpt-clean.ps1 -Apply` |
      
      Never point `nrpt-clean.ps1` at a live VPN's catch-all: the rule is doing its intended job, the VPN client will re-create it, and deleting it mid-session can leak DNS.
      
      ### macOS / Linux equivalents
      
      | Concern | macOS | Linux |
      |---|---|---|
      | Single-label / LAN names | mDNS (`.local`) via mDNSResponder; `dns-sd -q <name>` | LLMNR via systemd-resolved (`resolvectl query <name>`), avahi for `.local` |
      | VPN capture check | `scutil --dns` — resolver #1 pointing at the tunnel for `domain :` (default) | `resolvectl status` — `~.` routing domain on the VPN link captures everything |
      | SMB reachability | `nc -zv <ip> 445`, `smbutil view //<ip>` | `nc -zv <ip> 445`, `smbclient -L <ip>` |
      | Pin that beats VPN | `/etc/hosts` | `/etc/hosts` |
      
      ## Rung 4 — DNS Infrastructure
      
      **Question:** Does a DNS server actually answer queries?
      
      **Pass criteria:** All three resolvers (default + two public) return a name and address. The IPs may differ (different anycast points) — that's fine.
      
      **Fail → check:** UDP/53 outbound blocked (back to rung 3 raw test), router's DNS forwarder broken, ISP DNS hijack misconfigured.
      
      **Subtle bugs:**
      - If the resolver returns only IPv6 (AAAA) records for a site that should have IPv4, the resolver may be misconfigured for record-type ordering — apps preferring A records will hang
      - If different resolvers return wildly different IPs (different from anycast variation), you may be facing DNS poisoning or split-horizon weirdness
      
      ## Rung 5 — OS Resolver Path (THE INTERESTING LAYER)
      
      **Question:** Does the operating system's name-resolution chain actually return correct addresses?
      
      **THE SMOKING GUN:** Rung 4 passes (bypass tool works) but rung 5 fails (OS resolver times out). The DNS infrastructure is healthy but **something is hooking the system resolver path.**
      
      ### Windows suspects
      
      | Hook | Detection |
      |---|---|
      | **NRPT (Name Resolution Policy Table)** | `Get-DnsClientNrptRule \| Where Namespace -eq '.'` |
      | **HOSTS file** | `Get-Content $env:windir\System32\drivers\etc\hosts` |
      | **WFP callout driver** | `Get-CimInstance Win32_SystemDriver \| Where Name -match 'wfp\|epfw'` |
      | **DNS Client service hooked** | Third-party LSP catalog entries, dependent services |
      | **Local 127.0.0.1:53 proxy** | `Get-NetUDPEndpoint -LocalPort 53` |
      
      ### macOS suspects
      
      | Hook | Detection |
      |---|---|
      | **`/etc/resolver/<domain>` files** | `ls /etc/resolver/` — per-domain overrides, classic VPN residue |
      | **scutil DNS state** | `scutil --dns` — shows "resolver #N" entries; extras = potential hook |
      | **Configuration profiles (MDM)** | `profiles list -type configuration` — can install DNS overrides |
      | **mDNSResponder state** | `pgrep -x mDNSResponder` — if dead, all DNS dies |
      | **Third-party kext** | `kextstat \| grep -iE 'cisco\|anyconnect\|proton\|mullvad'` |
      | **PAC file / proxy** | `scutil --proxy` |
      
      ### Linux suspects
      
      | Hook | Detection |
      |---|---|
      | **`/etc/nsswitch.conf` hosts line** | NSS order excludes `resolve` or `dns` → bypass entirely |
      | **systemd-resolved state** | `resolvectl status` — per-link DNS / search domains |
      | **`/etc/resolv.conf` symlink** | `readlink /etc/resolv.conf` — should point at the stub on systemd systems |
      | **NetworkManager DNS mode** | `/etc/NetworkManager/NetworkManager.conf` `[main] dns=` |
      | **dnsmasq instance** | `pgrep -x dnsmasq` + `/etc/dnsmasq.d/` |
      | **Local 127.x:53 listener** | `ss -tulnp \| grep :53` |
      
      ## Rung 6 — Application Layer
      
      **Question:** Can a real application make a real HTTP request to a real hostname?
      
      **Fail BUT rung 5 passed → check:**
      
      | OS | Most common causes |
      |---|---|
      | Windows | WinHTTP proxy (`netsh winhttp show proxy`), cert store, TLS, IPv6 preference, app-specific config |
      | macOS | System proxy (`scutil --proxy`), keychain cert issues, IPv6 preference, app-specific config |
      | Linux | `http_proxy` / `https_proxy` env vars, CA bundle path, IPv6 preference, app-specific config |
      
      ## Discriminator Cheat Sheet
      
      | Symptoms | Diagnosis |
      |---|---|
      | Rung 1 fails | Hardware / driver / wifi association |
      | Rungs 1 pass, 2 fails | Routing or ISP |
      | Rungs 1-2 pass, 3 fails for all dests | Outbound firewall blocking the port |
      | Rungs 1-2 pass, 3 fails for specific dests | Destination-specific filter (AV "Encrypted DNS Detection") |
      | Rungs 1-3 pass, 4 fails | DNS server / forwarder broken upstream |
      | Rungs 1-4 pass, 5 fails | **OS resolver hook — go to per-OS dns-audit script** |
      | Rungs 1-5 pass, 6 fails | Proxy, cert store, TLS, IPv6 preference, app-specific |
      | All internet rungs pass, LAN name fails (host answers by IP) | **Rung 3.5 track — NRPT catch-all / suppressed LLMNR / credential keying (`smb-audit.ps1`)** |
      
      ## When the Ladder Doesn't Help
      
      Some failures are stateful or intermittent and won't show on a single probe pass:
      
      - **Time-based:** DNS works for 30s then breaks. Loop the probe; watch for transition timestamps.
      - **Per-network:** Fails on wifi, works on ethernet. Compare per-interface resolver config on each OS.
      - **Per-application:** Browsers fail, system tools work. Look at app-specific resolvers — Chrome / Firefox have their own DoH paths, curl has its own resolver, etc.
      
      For these, augment the ladder with continuous probing and per-interface comparison.
      
  • scripts
    • linux
      • dns-audit.sh 3.8 KB
        #!/usr/bin/env bash
        # net-ops :: linux/dns-audit.sh
        # Deep DNS forensics for Linux. Use when probe.sh shows rung 4 (dig) PASS
        # but rung 5 (getent / resolvectl) FAIL.
        
        set -u
        # Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
        # headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
        # (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
        __nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
        if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
        else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
        
        # shellcheck source=../_lib/redact.sh
        source "$(dirname "$0")/../_lib/redact.sh"
        parse_redact_flag "$@"
        maybe_redact_self "$@"
        
        term_header "/etc/nsswitch.conf (hosts line)"
        grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || echo "  (no hosts entry)"
        
        echo
        term_header "/etc/resolv.conf"
        if [[ -L /etc/resolv.conf ]]; then
            echo "  Type: symlink -> $(readlink /etc/resolv.conf)"
        else
            echo "  Type: regular file"
        fi
        echo "  Modified: $(stat -c '%y' /etc/resolv.conf 2>/dev/null || stat -f '%Sm' /etc/resolv.conf 2>/dev/null)"
        echo "  --- contents ---"
        cat /etc/resolv.conf 2>/dev/null | sed 's/^/  /'
        
        echo
        term_header "systemd-resolved"
        if systemctl is-active systemd-resolved >/dev/null 2>&1; then
            echo "  Service: active"
            echo "  --- resolvectl status ---"
            resolvectl status 2>/dev/null | sed 's/^/  /'
        else
            echo "  Service: inactive or not installed"
        fi
        
        echo
        term_header "NetworkManager DNS config"
        if command -v nmcli >/dev/null 2>&1; then
            echo "  --- nmcli dev show (DNS lines) ---"
            nmcli dev show 2>/dev/null | grep -E 'DEVICE|IP4.DNS|IP6.DNS|DOMAIN' | sed 's/^/  /'
            echo
            echo "  --- NetworkManager dns mode ---"
            awk '/\[main\]/,/\[/{if(/^dns/) print}' /etc/NetworkManager/NetworkManager.conf 2>/dev/null | sed 's/^/  /' || true
            ls -la /etc/NetworkManager/conf.d/ 2>/dev/null | sed 's/^/  /' || true
        else
            echo "  nmcli not installed"
        fi
        
        echo
        term_header "dnsmasq"
        if pgrep -x dnsmasq >/dev/null; then
            pid=$(pgrep -x dnsmasq | head -1)
            echo "  Running, PID $pid"
            ps -o command -p "$pid" 2>/dev/null | sed 's/^/  /'
        else
            echo "  not running"
        fi
        for d in /etc/dnsmasq.d /etc/NetworkManager/dnsmasq.d; do
            [[ -d "$d" ]] && { echo "  $d contents:"; ls "$d" 2>/dev/null | sed 's/^/    /'; }
        done
        
        echo
        term_header "Local DNS listeners"
        ss -tulnp 2>/dev/null | awk 'NR==1 || $5 ~ /:53$/' | sed 's/^/  /'
        
        echo
        term_header "/etc/hosts (non-comment)"
        grep -vE '^\s*(#|$)' /etc/hosts 2>/dev/null | sed 's/^/  /' || echo "  (no custom entries)"
        
        echo
        term_header "VPN / WireGuard interfaces"
        ip -br link 2>/dev/null | awk '/^(wg|tun|tap|nordlynx|proton|mullvad|nextdns)/' | sed 's/^/  /' || true
        if command -v wg >/dev/null 2>&1; then
            echo "  --- wg show ---"
            wg show 2>/dev/null | sed 's/^/  /' | head -30 || true
        fi
        
        echo
        term_header "ATTRIBUTION HINTS"
        # Inspect nameservers visible across the stack for known patterns
        ns_list=$( {
            awk '/^nameserver/{print $2}' /etc/resolv.conf 2>/dev/null
            resolvectl status 2>/dev/null | awk '/Current DNS Server:|DNS Servers:/{for(i=4;i<=NF;i++)print $i}'
            nmcli -t -f IP4.DNS,IP6.DNS dev show 2>/dev/null | awk -F: '{print $2}'
        } | sort -u | grep -v '^$' )
        
        while read -r n; do
            [[ -z "$n" ]] && continue
            case "$n" in
                10.2.0.*)              echo "  $n :: likely Proton VPN gateway" ;;
                10.64.0.*)             echo "  $n :: likely Mullvad gateway" ;;
                10.211.*|10.212.*)     echo "  $n :: likely Cisco AnyConnect" ;;
                100.100.100.100)       echo "  $n :: Tailscale MagicDNS (expected)" ;;
                127.0.0.53)            echo "  $n :: systemd-resolved stub (expected on most systems)" ;;
                127.0.0.1|127.0.0.2)   echo "  $n :: local DNS proxy (dnsmasq, NextDNS, AdGuard, etc.)" ;;
            esac
        done <<< "$ns_list"
        
      • probe.sh 14.5 KB
        #!/usr/bin/env bash
        # net-ops :: linux/probe.sh
        # Full layered diagnostic ladder for Linux network troubleshooting.
        # Outputs structured [PASS]/[FAIL] lines so a human or LLM can scan for
        # the first FAIL and drill in.
        
        set -u
        
        TEST_HOST="${TEST_HOST:-google.com}"
        TEST_IPS=("1.1.1.1" "8.8.8.8")
        TIMEOUT="${TIMEOUT:-5}"
        
        for arg in "$@"; do
            case "$arg" in
                --help|-h)
                    cat <<EOF
        Usage: $0 [--redact] [--json] [--quick]
        
          --redact   Mask private IPs, MAC addresses, and *.ts.net tailnet names
          --json     Newline-delimited JSON output (for piping to jq, dashboards)
          --quick    Skip rungs 1-4 and 7 if the last full run cached as healthy
                     (cache: \${TMPDIR:-/tmp}/net-ops/last-state.json, TTL 10min)
        
        Compose freely: --json + --redact emits sanitized NDJSON.
        Env: TEST_HOST (default google.com), TIMEOUT (default 5s).
        EOF
                    exit 0 ;;
            esac
        done
        
        # shellcheck source=../_lib/redact.sh
        source "$(dirname "$0")/../_lib/redact.sh"
        # shellcheck source=../_lib/output.sh
        source "$(dirname "$0")/../_lib/output.sh"
        PANEL_TITLE="linux probe"
        parse_redact_flag "$@"
        parse_output_flags "$@"
        maybe_redact_self "$@"
        
        # ---------------------------------------------------------------------------
        section "1. LINK LAYER"
        # ---------------------------------------------------------------------------
        ip -br link 2>/dev/null | awk '$2=="UP"{print $1}' | while read -r dev; do
            [[ "$dev" == "lo" ]] && continue
            addr=$(ip -br -4 addr show "$dev" 2>/dev/null | awk '{print $3}')
            pass "Interface $dev UP" "${addr:-no IPv4}"
        done
        
        GATEWAY=$(ip route show default 2>/dev/null | awk '/default/{print $3; exit}')
        DEFAULT_IF=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
        [[ -n "$GATEWAY" ]] && pass "Default gateway" "$GATEWAY via $DEFAULT_IF" || fail "Default gateway" "none configured"
        
        # ---------------------------------------------------------------------------
        section "2. IP / ICMP REACHABILITY"
        # ---------------------------------------------------------------------------
        [[ -n "${GATEWAY:-}" ]] && {
            if ping -c 2 -W "$TIMEOUT" "$GATEWAY" >/dev/null 2>&1; then pass "Ping gateway $GATEWAY"; else fail "Ping gateway $GATEWAY"; fi
        }
        for ip in "${TEST_IPS[@]}"; do
            if ping -c 2 -W "$TIMEOUT" "$ip" >/dev/null 2>&1; then pass "Ping $ip"; else fail "Ping $ip"; fi
        done
        
        # ---------------------------------------------------------------------------
        section "3. TCP/UDP SOCKET REACHABILITY"
        # ---------------------------------------------------------------------------
        for ip in "${TEST_IPS[@]}"; do
            if timeout "$TIMEOUT" bash -c "</dev/tcp/$ip/443" 2>/dev/null; then pass "TCP/443 -> $ip"; else fail "TCP/443 -> $ip"; fi
            if timeout "$TIMEOUT" bash -c "</dev/tcp/$ip/53"  2>/dev/null; then pass "TCP/53  -> $ip"; else fail "TCP/53  -> $ip"; fi
        done
        
        # Raw UDP/53 via dig with explicit server — bypasses /etc/resolv.conf
        for ip in "${TEST_IPS[@]}"; do
            if result=$(dig +short +time="$TIMEOUT" +tries=1 @"$ip" "$TEST_HOST" 2>&1) && [[ -n "$result" ]] && [[ ! "$result" =~ "timed out"|"connection refused" ]]; then
                pass "UDP/53 -> $ip (dig)" "$(echo "$result" | head -1)"
            else
                fail "UDP/53 -> $ip (dig)" "$result"
            fi
        done
        
        # ---------------------------------------------------------------------------
        section "4. DNS INFRASTRUCTURE (bypass tools)"
        # ---------------------------------------------------------------------------
        # dig uses its own resolver — does NOT touch glibc NSS chain
        for srv in "" "${TEST_IPS[@]}"; do
            if [[ -z "$srv" ]]; then
                out=$(dig +short +time="$TIMEOUT" +tries=1 "$TEST_HOST" 2>&1)
                label="default"
            else
                out=$(dig +short +time="$TIMEOUT" +tries=1 @"$srv" "$TEST_HOST" 2>&1)
                label="$srv"
            fi
            if [[ -n "$out" && ! "$out" =~ "timed out"|"connection refused" ]]; then
                pass "dig via $label" "$(echo "$out" | head -1)"
            else
                fail "dig via $label" "$out"
            fi
        done
        
        # ---------------------------------------------------------------------------
        section "5. LINUX RESOLVER PATH (the hook layer)"
        # ---------------------------------------------------------------------------
        # getent uses glibc NSS — goes through the whole system resolver chain
        if out=$(getent hosts "$TEST_HOST" 2>&1) && [[ -n "$out" ]]; then
            addr=$(echo "$out" | awk '{print $1; exit}')
            pass "getent hosts (NSS path)" "$addr"
        else
            fail "getent hosts (NSS path)" "$out"
        fi
        
        # resolvectl query if systemd-resolved present
        if command -v resolvectl >/dev/null 2>&1; then
            if out=$(resolvectl query "$TEST_HOST" 2>&1) && echo "$out" | grep -q "^$TEST_HOST:"; then
                addr=$(echo "$out" | awk '/^[^:]+:.+[0-9]+\./{print $2; exit}')
                pass "resolvectl query" "$addr"
            else
                fail "resolvectl query" "$(echo "$out" | head -2)"
            fi
        fi
        
        # nsswitch.conf — name resolution order
        echo "  /etc/nsswitch.conf hosts line:"
        grep "^hosts:" /etc/nsswitch.conf 2>/dev/null | sed 's/^/    /'
        
        # /etc/resolv.conf — is it the systemd-resolved stub, NetworkManager's, or static?
        echo "  /etc/resolv.conf:"
        if [[ -L /etc/resolv.conf ]]; then
            target=$(readlink /etc/resolv.conf)
            echo "    symlink -> $target"
        fi
        head -5 /etc/resolv.conf 2>/dev/null | sed 's/^/    /'
        
        # Active resolver listeners on 127.x:53
        echo "  Local DNS listeners on 127.0.0.x:53:"
        ss -tulnp 2>/dev/null | awk '$5 ~ /^127\./ && $5 ~ /:53$/' | sed 's/^/    /' || true
        
        # systemd-resolved status (if present)
        if systemctl is-active systemd-resolved >/dev/null 2>&1; then
            echo "  systemd-resolved active. Per-link DNS:"
            resolvectl status 2>/dev/null | awk '
                /^Link [0-9]+/{link=$0; show=0; printed=0}
                /Current DNS Server:|DNS Servers:|DNS Domain:/{
                    if(!printed){print "    "link; printed=1}
                    print "      "$0
                }
            ' | head -40
        fi
        
        # ---------------------------------------------------------------------------
        # Time-sync deep-dive: HTTP Date drift + check timedatectl/chrony/ntpd status
        remote_date=$(curl -sIA 'net-ops-probe' --max-time 5 https://www.google.com 2>/dev/null | awk -F': ' 'tolower($1)=="date"{print $2; exit}' | tr -d '\r')
        drift_ok=1
        drift_detail=""
        if [[ -n "$remote_date" ]]; then
            remote_epoch=$(date -d "$remote_date" +%s 2>/dev/null)
            if [[ -n "$remote_epoch" ]]; then
                local_epoch=$(date +%s)
                drift=$(( local_epoch - remote_epoch ))
                abs_drift=${drift#-}
                if [[ "$abs_drift" -lt 300 ]]; then
                    drift_detail="${drift}s vs HTTP Date (within ±5min)"
                else
                    drift_ok=0
                    drift_detail="${drift}s drift — will break TLS cert validation"
                fi
            fi
        fi
        
        # Detect which time daemon and its sync state
        sync_detail=""
        if command -v timedatectl >/dev/null 2>&1; then
            sync_state=$(timedatectl show 2>/dev/null | awk -F= '/^NTPSynchronized=/{print $2}')
            sync_detail="systemd-timesyncd NTPSynchronized=$sync_state"
        elif command -v chronyc >/dev/null 2>&1; then
            stratum=$(chronyc tracking 2>/dev/null | awk -F': ' '/Stratum/{print $2}')
            sync_detail="chronyd stratum=$stratum"
            [[ "$stratum" == "16" ]] && drift_ok=0
        elif command -v ntpq >/dev/null 2>&1; then
            sync_detail="ntpd present (run 'ntpq -p' for peer status)"
        fi
        
        combined="$drift_detail${sync_detail:+; $sync_detail}"
        if [[ "$drift_ok" -eq 1 ]]; then
            pass "Time sync" "$combined"
        else
            fail "Time sync" "$combined"
        fi
        
        # MTU / path-MTU discovery. Linux uses -M do (don't fragment).
        if ping -M do -s 1472 -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
            pass "Path MTU 1500 (1472-byte DF payload)" "to 1.1.1.1"
        else
            if ping -M do -s 1400 -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
                fail "Path MTU 1500 (1472-byte DF payload)" "1500 fails, 1428+ works — path MTU < 1500 (VPN/PPPoE?)"
            else
                pass "Path MTU test inconclusive" "ICMP DF blocked or destination unreachable"
            fi
        fi
        
        # IPv6 deep-dive — classifies v6 stack state across four meaningful tiers.
        v6_state=""
        v6_detail=""
        
        v6_addrs=$(ip -6 -br addr show scope global 2>/dev/null | awk '{for(i=3;i<=NF;i++) print $1" "$i}' | grep -v '^lo ')
        v6_global=$(printf '%s\n' "$v6_addrs" | awk '$2 !~ /^fd/ && $2 !~ /^fc/{print; exit}')
        v6_default=$(ip -6 route show default 2>/dev/null | head -1)
        
        if [[ -z "$v6_addrs" ]]; then
            v6_state="disabled"
            v6_detail="no global v6 addresses — IPv6 disabled or unconfigured (check sysctl net.ipv6.conf.all.disable_ipv6)"
        elif [[ -z "$v6_global" ]]; then
            v6_state="ula_only"
            v6_detail="only ULA (fc00::/7) addresses present — router not delegating public v6 prefix"
        elif [[ -z "$v6_default" ]]; then
            v6_state="no_route"
            v6_detail="global v6 address present but no default route — RA not received (check accept_ra sysctl)"
        else
            aaaa=$(dig +short +time=2 +tries=1 AAAA "$TEST_HOST" 2>/dev/null | head -1)
            if [[ -n "$aaaa" ]] && curl -6 -sS -o /dev/null --max-time 4 "https://$TEST_HOST" 2>/dev/null; then
                v6_state="healthy"
                v6_detail="global addr + default route + curl -6 works"
            else
                v6_state="path_broken"
                v6_detail="addr present, default route present, but curl -6 fails — firewall or ISP black-holing"
            fi
        fi
        
        case "$v6_state" in
            disabled|healthy) pass "IPv6 stack ($v6_state)" "$v6_detail" ;;
            *) fail "IPv6 stack ($v6_state)" "$v6_detail" ;;
        esac
        
        # ---------------------------------------------------------------------------
        section "6. APPLICATION LAYER (real HTTP request)"
        # ---------------------------------------------------------------------------
        for url in "https://www.google.com" "https://github.com"; do
            if out=$(curl -sS -o /dev/null -w "%{http_code} %{size_download}b" --max-time "$TIMEOUT" "$url" 2>&1); then
                pass "GET $url" "$out"
            else
                fail "GET $url" "$out"
            fi
        done
        
        # ---------------------------------------------------------------------------
        section "7. KNOWN VPN / DNS CLIENT FOOTPRINT"
        # ---------------------------------------------------------------------------
        # Browser DoH state — Chrome / Brave / Edge / Firefox bypass system DNS when DoH set.
        browser_findings=""
        for label_prefs in \
            "Chrome:$HOME/.config/google-chrome/Default/Preferences" \
            "Chromium:$HOME/.config/chromium/Default/Preferences" \
            "Brave:$HOME/.config/BraveSoftware/Brave-Browser/Default/Preferences" \
            "Edge:$HOME/.config/microsoft-edge/Default/Preferences"; do
            label="${label_prefs%%:*}"
            prefs="${label_prefs#*:}"
            [[ -f "$prefs" ]] || continue
            mode=$(perl -ne 'if (/"dns_over_https"\s*:\s*\{[^}]*"mode"\s*:\s*"([^"]+)"/) { print "$1\n"; exit }' "$prefs" 2>/dev/null)
            templates=$(perl -ne 'if (/"dns_over_https"\s*:\s*\{[^}]*"templates"\s*:\s*"([^"]+)"/) { print "$1\n"; exit }' "$prefs" 2>/dev/null)
            if [[ -n "$mode" ]]; then
                browser_findings+="    $label DoH: mode=$mode${templates:+, server=$templates}\n"
            else
                browser_findings+="    $label installed, DoH: not configured (system DNS)\n"
            fi
        done
        for fx_prefs in "$HOME/.mozilla/firefox"/*.default*/prefs.js; do
            [[ -f "$fx_prefs" ]] || continue
            trr_mode=$(awk -F'"' '/"network.trr.mode"/{print $4; exit}' "$fx_prefs" 2>/dev/null)
            trr_uri=$(awk -F'"' '/"network.trr.uri"/{print $4; exit}' "$fx_prefs" 2>/dev/null)
            case "${trr_mode:-0}" in
                2) state="enabled (with system fallback)" ;;
                3) state="enabled (no fallback)" ;;
                5) state="disabled by policy" ;;
                *) state="off (system DNS)" ;;
            esac
            browser_findings+="    Firefox DoH: $state${trr_uri:+, server=$trr_uri}\n"
            break
        done
        if [[ -n "$browser_findings" ]]; then
            info "  Browser DoH state (browsers may bypass system DNS):"
            printf '%b' "$browser_findings"
        fi
        
        KNOWN=(
            /etc/openvpn /etc/wireguard /opt/cisco /etc/proton-vpn /etc/mullvad-vpn
            /opt/nordvpn /etc/NetworkManager/dnsmasq.d /etc/dnsmasq.d
            /etc/cloudflared /etc/nextdns.conf
        )
        for p in "${KNOWN[@]}"; do
            [[ -e "$p" ]] && echo "  Found: $p"
        done
        
        # Running VPN / DNS proxy processes
        echo "  VPN / DNS proxy processes:"
        pgrep -af 'openvpn|wireguard|wg-quick|mullvad|proton|nordvpn|cloudflared|nextdns|dnsmasq|stubby|dnscrypt' 2>/dev/null | head -10 | sed 's/^/    /' || true
        
        # ---------------------------------------------------------------------------
        section "8. ENVIRONMENT (WSL / container detection)"
        # ---------------------------------------------------------------------------
        env_type=""
        if [[ -f /proc/sys/fs/binfmt_misc/WSLInterop ]] || grep -qi microsoft /proc/version 2>/dev/null; then
            env_type="WSL2"
        elif [[ -f /.dockerenv ]]; then
            env_type="Docker container"
        elif grep -qE 'docker|containerd|kubepods' /proc/1/cgroup 2>/dev/null; then
            env_type="container (cgroup signature)"
        fi
        
        if [[ -z "$env_type" ]]; then
            info "  Bare-metal / VM Linux (no WSL/container signature)"
        else
            info "  Detected environment: $env_type"
            case "$env_type" in
                WSL2*)
                    info "  WSL2 has bespoke DNS handling. Key files if DNS misbehaves:"
                    info "    /etc/wsl.conf       — controls generateResolvConf"
                    info "    /etc/resolv.conf    — auto-generated by WSL unless wsl.conf opts out"
                    info "    Host Windows DNS    — affects WSL DNS via mirrored mode"
                    info "  Fix pattern: edit /etc/wsl.conf, set [network] generateResolvConf=false, write static /etc/resolv.conf"
                    [[ -f /etc/wsl.conf ]] && { info "    --- /etc/wsl.conf ---"; sed 's/^/      /' /etc/wsl.conf; }
                    info "    --- /etc/resolv.conf head ---"
                    head -5 /etc/resolv.conf 2>/dev/null | sed 's/^/      /'
                    ;;
                Docker*|container*)
                    info "  Container DNS inherits from host or --dns flag at run time."
                    info "    /etc/resolv.conf here is set by runtime, not user."
                    info "  If broken inside container but fine on host: check 'docker network inspect' / runtime config."
                    ;;
            esac
        fi
        
        emit_summary
        if [[ "$JSON_MODE" -eq 0 ]]; then
            if [[ -n "$FIRST_FAIL" ]]; then
                case "$FIRST_FAIL" in
                    *"LINK LAYER"*)    echo "  Next: check ip link / ip addr, DHCP, NetworkManager state" ;;
                    *"SOCKET"*)        echo "  Next: check iptables/nftables OUTPUT chain; AV protocol filtering; consumer router DoH IP blocking" ;;
                    *"ICMP"*|*"IP /"*) echo "  Next: check ip route, ISP/upstream connectivity" ;;
                    *"DNS INFRASTRUCTURE"*) echo "  Next: check UDP/53 outbound, /etc/resolv.conf upstream" ;;
                    *"RESOLVER PATH"*) echo "  Next: bash scripts/linux/dns-audit.sh   # drill rung 5 (the hook layer)" ;;
                    *"APPLICATION"*)   echo "  Next: check http_proxy/https_proxy env, CA bundle, IPv6 preference" ;;
                    *) echo "  Next: re-run with --verbose; check references/common-culprits.md" ;;
                esac
            fi
            echo
            echo "=== END PROBE ==="
        fi
        
      • resolved-reset.sh 3.3 KB
        #!/usr/bin/env bash
        # net-ops :: linux/resolved-reset.sh
        # Reset systemd-resolved state when per-link DNS gets stuck (typical after
        # VPN disconnect leaves stale per-link DNS / domain settings).
        #
        # Defaults to DRY RUN — pass --apply to actually act.
        # Requires sudo for the apply path.
        
        set -eu
        # Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
        # headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
        # (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
        __nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
        if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
        else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
        
        APPLY=0
        for arg in "$@"; do
            case "$arg" in
                --apply) APPLY=1 ;;
                --help|-h)
                    cat <<EOF
        Usage: $0 [--apply]
        
        Diagnoses and (with --apply) resets systemd-resolved per-link DNS state.
        
          --apply    Flush caches and revert each link's DNS to NetworkManager/networkd defaults
                     (default: dry-run, prints what would happen)
        EOF
                    exit 0 ;;
            esac
        done
        
        if ! systemctl is-active systemd-resolved >/dev/null 2>&1; then
            echo "systemd-resolved is not active. This script only applies when it is."
            echo "On non-systemd-resolved systems, edit /etc/resolv.conf or NetworkManager config directly."
            exit 0
        fi
        
        term_header "BEFORE"
        resolvectl status 2>/dev/null | head -60
        
        # Find links with non-empty per-link DNS (potential stale state)
        LINKS_WITH_DNS=$(resolvectl status 2>/dev/null | awk '
            /^Link [0-9]+ \(/{ split($0,a," \\("); split(a[2],b,")"); link=b[1]; ifn=a[1]; sub("Link ","",ifn); has=0 }
            /Current DNS Server:|DNS Servers:/{ if(NF>3){print ifn"|"link} }
        ' | sort -u)
        
        if [[ -z "$LINKS_WITH_DNS" ]]; then
            echo
            echo "No links have explicit DNS set. Nothing to reset."
            exit 0
        fi
        
        echo
        term_header "LINKS WITH EXPLICIT DNS"
        echo "$LINKS_WITH_DNS" | while IFS='|' read -r idx name; do
            echo "  Link $idx ($name)"
        done
        
        if [[ "$APPLY" -eq 0 ]]; then
            echo
            echo "DRY RUN — pass --apply to actually reset these links and flush caches."
            exit 0
        fi
        
        if [[ "$EUID" -ne 0 ]]; then
            echo "Need root. Re-running with sudo..."
            exec sudo "$0" --apply
        fi
        
        echo
        term_header "RESETTING"
        echo "$LINKS_WITH_DNS" | while IFS='|' read -r idx name; do
            if resolvectl revert "$name" 2>/dev/null; then
                echo "[OK]   reverted $name"
            else
                echo "[WARN] revert failed for $name (may be a VPN tunnel — manual cleanup may be needed)"
            fi
        done
        
        echo
        term_header "FLUSHING CACHE"
        resolvectl flush-caches && echo "  cache flushed"
        
        # Restart for good measure if user really wanted a reset
        systemctl restart systemd-resolved
        echo "  systemd-resolved restarted"
        
        echo
        term_header "VERIFICATION"
        if out=$(getent hosts google.com 2>&1) && [[ -n "$out" ]]; then
            echo "[PASS] getent hosts google.com -> $(echo "$out" | awk '{print $1}')"
        else
            echo "[FAIL] getent still failing. Check /etc/nsswitch.conf and /etc/resolv.conf."
        fi
        
        if curl -sS -o /dev/null -w "[PASS] HTTPS google.com -> HTTP %{http_code}\n" --max-time 8 https://www.google.com 2>&1; then
            :
        else
            echo "[FAIL] HTTPS still broken."
        fi
        
        echo
        term_header "AFTER"
        resolvectl status 2>/dev/null | head -40
        
    • macos
      • dns-audit.sh 4.2 KB
        #!/usr/bin/env bash
        # net-ops :: macos/dns-audit.sh
        # Deep DNS forensics for macOS. Use when probe.sh shows rung 4 (dig) PASS
        # but rung 5 (dscacheutil) FAIL — that signature points at a hook in the
        # macOS resolver chain.
        
        set -u
        # Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
        # headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
        # (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
        __nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
        if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
        else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
        
        # shellcheck source=../_lib/redact.sh
        source "$(dirname "$0")/../_lib/redact.sh"
        parse_redact_flag "$@"
        maybe_redact_self "$@"
        
        term_header "scutil --dns (FULL)"
        scutil --dns 2>/dev/null
        
        echo
        term_header "/etc/resolver/* (per-domain DNS overrides — VPN clients use these)"
        if [[ -d /etc/resolver ]] && [[ -n "$(ls -A /etc/resolver 2>/dev/null)" ]]; then
            for f in /etc/resolver/*; do
                [[ -f "$f" ]] || continue
                echo "--- $f ---"
                echo "  modified: $(stat -f '%Sm' "$f" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null)"
                cat "$f" | sed 's/^/  /'
            done
        else
            echo "/etc/resolver/ empty or missing — no per-domain overrides."
        fi
        
        echo
        term_header "Configuration profiles with DNS settings"
        profiles list -type configuration 2>/dev/null | head -40
        echo
        echo "  (run 'sudo profiles show -type configuration' for full payloads)"
        
        echo
        term_header "/etc/hosts (non-comment lines)"
        grep -vE '^\s*(#|$)' /etc/hosts 2>/dev/null || echo "  (no custom entries)"
        
        echo
        term_header "/etc/resolv.conf (legacy, usually a stub on macOS)"
        if [[ -f /etc/resolv.conf ]]; then
            cat /etc/resolv.conf
        else
            echo "  not present"
        fi
        
        echo
        term_header "mDNSResponder state"
        if pgrep -x mDNSResponder >/dev/null; then
            pid=$(pgrep -x mDNSResponder | head -1)
            echo "PID: $pid"
            ps -o pid,etime,command -p "$pid" 2>/dev/null
        fi
        
        echo
        term_header "Network services priority order"
        networksetup -listnetworkserviceorder 2>/dev/null | head -30
        
        echo
        term_header "DNS servers per active service"
        networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r svc; do
            [[ "$svc" == \** ]] && continue  # disabled
            dns=$(networksetup -getdnsservers "$svc" 2>/dev/null)
            echo "  $svc: $dns"
        done
        
        echo
        term_header "Search domains per active service"
        networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r svc; do
            [[ "$svc" == \** ]] && continue
            sd=$(networksetup -getsearchdomains "$svc" 2>/dev/null)
            echo "  $svc: $sd"
        done
        
        echo
        term_header "Third-party network kexts loaded"
        kextstat 2>/dev/null | grep -iE 'cisco|anyconnect|proton|mullvad|nord|littlesnitch|lulu|nextdns|warp' || echo "  (none detected)"
        
        echo
        term_header "ATTRIBUTION HINTS"
        # Aggregate every nameserver we can see across all resolver surfaces, then
        # pattern-match each unique entry to a known VPN/DNS client signature.
        ns_list=$( {
            [[ -d /etc/resolver ]] && grep -h '^nameserver' /etc/resolver/* 2>/dev/null | awk '{print $2}'
            scutil --dns 2>/dev/null | awk '/nameserver\[[0-9]+\]/{print $3}'
            networksetup -listallnetworkservices 2>/dev/null | tail -n +2 | while read -r svc; do
                [[ "$svc" == \** ]] && continue
                networksetup -getdnsservers "$svc" 2>/dev/null | grep -E '^[0-9a-f:.]+$' || true
            done
        } | sort -u | grep -v '^$' )
        
        if [[ -z "$ns_list" ]]; then
            echo "  (no nameservers found)"
        fi
        
        while read -r n; do
            [[ -z "$n" ]] && continue
            case "$n" in
                10.2.0.*)        echo "  $n :: likely Proton VPN gateway" ;;
                10.64.0.*)       echo "  $n :: likely Mullvad gateway" ;;
                10.211.*|10.212.*) echo "  $n :: likely Cisco AnyConnect" ;;
                10.5.0.*)        echo "  $n :: likely NordVPN gateway" ;;
                100.100.100.100) echo "  $n :: Tailscale MagicDNS (expected)" ;;
                127.0.0.1|127.0.0.2|::1) echo "  $n :: local DNS proxy (NextDNS, AdGuard, dnsmasq, etc.)" ;;
                1.1.1.1|1.0.0.1) echo "  $n :: Cloudflare public DNS" ;;
                8.8.8.8|8.8.4.4) echo "  $n :: Google public DNS" ;;
                9.9.9.9|149.112.112.112) echo "  $n :: Quad9 public DNS" ;;
            esac
        done <<< "$ns_list"
        
      • probe.sh 17.4 KB
        #!/usr/bin/env bash
        # net-ops :: macos/probe.sh
        # Full layered diagnostic ladder for macOS network troubleshooting.
        # Outputs structured [PASS]/[FAIL] lines so a human or LLM can scan for
        # the first FAIL and drill in.
        
        set -u
        
        TEST_HOST="${TEST_HOST:-google.com}"
        TEST_IPS=("1.1.1.1" "8.8.8.8")
        TIMEOUT="${TIMEOUT:-5}"
        
        VERBOSE=0
        for arg in "$@"; do
            case "$arg" in
                --verbose|-v) VERBOSE=1 ;;
                --help|-h)
                    cat <<EOF
        Usage: $0 [--redact] [--verbose] [--json] [--quick]
        
          --redact   Mask private IPs, MAC addresses, and *.ts.net tailnet names
          --verbose  Full scutil --dns dump (default: condensed one-line-per-resolver)
          --json     Newline-delimited JSON output (for piping to jq, dashboards)
          --quick    Skip rungs 1-4 and 7 if the last full run cached as healthy
                     (cache: \${TMPDIR}/net-ops/last-state.json, TTL 10min)
        
        Compose freely: --json + --redact emits sanitized NDJSON.
        EOF
                    exit 0 ;;
            esac
        done
        
        # shellcheck source=../_lib/redact.sh
        source "$(dirname "$0")/../_lib/redact.sh"
        # shellcheck source=../_lib/output.sh
        source "$(dirname "$0")/../_lib/output.sh"
        # shellcheck source=../_lib/cache.sh
        source "$(dirname "$0")/../_lib/cache.sh"
        PANEL_TITLE="macos probe"
        parse_redact_flag "$@"
        parse_output_flags "$@"
        parse_quick_flag "$@"
        maybe_redact_self "$@"
        
        if cache_indicates_healthy; then
            info "  [--quick: last full run was healthy, skipping rungs 1-4 and 7]"
        fi
        
        # ---------------------------------------------------------------------------
        if should_run_rung 1; then
        section "1. LINK LAYER"
        # ---------------------------------------------------------------------------
        ACTIVE_IFS=$(networksetup -listallhardwareports 2>/dev/null | awk '/Hardware Port/{port=$3} /Device/{print port" "$2}' || true)
        echo "$ACTIVE_IFS" | while read -r line; do
            [[ -z "$line" ]] && continue
            name="${line% *}"; dev="${line##* }"
            status=$(ifconfig "$dev" 2>/dev/null | awk '/status:/{print $2; exit}')
            if [[ "$status" == "active" ]]; then
                ip=$(ifconfig "$dev" 2>/dev/null | awk '/inet /{print $2; exit}')
                pass "Interface $name ($dev) active" "$ip"
            fi
        done
        
        GATEWAY=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}')
        DEFAULT_IF=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')
        [[ -n "$GATEWAY" ]] && pass "Default gateway" "$GATEWAY via $DEFAULT_IF" || fail "Default gateway" "none configured"
        
        fi  # end rung 1
        
        # ---------------------------------------------------------------------------
        if should_run_rung 2; then
        section "2. IP / ICMP REACHABILITY"
        # ---------------------------------------------------------------------------
        [[ -n "${GATEWAY:-}" ]] && {
            if ping -c 2 -W "${TIMEOUT}000" "$GATEWAY" >/dev/null 2>&1; then pass "Ping gateway $GATEWAY"; else fail "Ping gateway $GATEWAY"; fi
        }
        for ip in "${TEST_IPS[@]}"; do
            if ping -c 2 -W "${TIMEOUT}000" "$ip" >/dev/null 2>&1; then pass "Ping $ip"; else fail "Ping $ip"; fi
        done
        
        fi  # end rung 2
        
        # ---------------------------------------------------------------------------
        if should_run_rung 3; then
        section "3. TCP/UDP SOCKET REACHABILITY"
        # ---------------------------------------------------------------------------
        for ip in "${TEST_IPS[@]}"; do
            if nc -zv -G "$TIMEOUT" "$ip" 443 >/dev/null 2>&1; then pass "TCP/443 -> $ip"; else fail "TCP/443 -> $ip"; fi
            if nc -zv -G "$TIMEOUT" "$ip" 53 >/dev/null 2>&1; then pass "TCP/53  -> $ip"; else fail "TCP/53  -> $ip"; fi
        done
        
        # Raw UDP/53 — uses dig with explicit server, bypasses /etc/resolv.conf
        for ip in "${TEST_IPS[@]}"; do
            if dig +short +time="$TIMEOUT" +tries=1 @"$ip" "$TEST_HOST" >/dev/null 2>&1; then
                result=$(dig +short +time="$TIMEOUT" +tries=1 @"$ip" "$TEST_HOST" | head -1)
                pass "UDP/53 -> $ip (dig)" "$result"
            else
                fail "UDP/53 -> $ip (dig)"
            fi
        done
        
        fi  # end rung 3
        
        # ---------------------------------------------------------------------------
        if should_run_rung 4; then
        section "4. DNS INFRASTRUCTURE (bypass tools)"
        # ---------------------------------------------------------------------------
        # dig uses its own resolver — does NOT touch macOS DNS resolution chain
        for srv in "" "${TEST_IPS[@]}"; do
            if [[ -z "$srv" ]]; then
                out=$(dig +short +time="$TIMEOUT" +tries=1 "$TEST_HOST" 2>&1)
                label="default"
            else
                out=$(dig +short +time="$TIMEOUT" +tries=1 @"$srv" "$TEST_HOST" 2>&1)
                label="$srv"
            fi
            if [[ -n "$out" && ! "$out" =~ "timed out"|"connection refused" ]]; then
                pass "dig via $label" "$(echo "$out" | head -1)"
            else
                fail "dig via $label" "$out"
            fi
        done
        
        fi  # end rung 4
        
        # ---------------------------------------------------------------------------
        section "5. macOS RESOLVER PATH (the hook layer)"
        # ---------------------------------------------------------------------------
        # dscacheutil uses the macOS resolver chain — goes through everything
        out=$(dscacheutil -q host -a name "$TEST_HOST" 2>&1)
        if echo "$out" | grep -q "ip_address:"; then
            addr=$(echo "$out" | awk '/ip_address:/{print $2; exit}')
            pass "dscacheutil (system resolver)" "$addr"
        else
            fail "dscacheutil (system resolver)" "$(echo "$out" | head -3)"
        fi
        
        # /etc/resolver/* — per-domain overrides, classic VPN residue
        if [[ -d /etc/resolver ]]; then
            resolver_files=$(ls /etc/resolver/ 2>/dev/null)
            if [[ -n "$resolver_files" ]]; then
                echo "  /etc/resolver/ contents (per-domain DNS overrides):"
                for f in /etc/resolver/*; do
                    [[ -f "$f" ]] || continue
                    domain="${f##*/}"
                    ns=$(awk '/^nameserver/{print $2}' "$f" | tr '\n' ' ')
                    echo "    $domain -> $ns"
                done
            fi
        fi
        
        # scutil DNS state — the authoritative view of macOS resolver config
        if [[ "$VERBOSE" -eq 1 ]]; then
            echo "  scutil --dns (full):"
            scutil --dns 2>/dev/null | sed 's/^/    /'
        else
            # Condensed: one line per resolver — scope (via domain or search), nameservers, order
            echo "  scutil --dns (condensed, --verbose for full):"
            scutil --dns 2>/dev/null | awk '
                /^resolver #/{ if(num){flush()} num=$2; sub(/#/,"",num); scope=""; ns=""; ord="" }
                /search domain\[0\]/{ scope="search="$NF }
                /domain[[:space:]]*:/{ scope="domain="$NF }
                /options/{ if($NF~/mdns/) scope="mdns" }
                /nameserver\[[0-9]+\]/{ ns=ns?ns","$NF:$NF }
                /order[[:space:]]*:/{ ord=$NF }
                function flush() {
                    if (!scope) scope="default"
                    print "    #"num"  scope="scope"  via="ns"  order="ord
                }
                END{ if(num) flush() }
            '
        fi
        
        # Configuration profiles (MDM / VPN-installed). Without sudo we only see user-scope.
        profile_count=$(profiles list -type configuration 2>/dev/null | grep -c "attribute:" 2>/dev/null)
        profile_count="${profile_count:-0}"
        if [[ "$profile_count" =~ ^[0-9]+$ ]] && (( profile_count > 0 )); then
            echo "  Configuration profiles installed (user scope): $profile_count"
            echo "    For full detail incl. system profiles: sudo profiles list -type configuration"
        fi
        
        # Local DNS proxy detection — derived from scutil (works unprivileged).
        # Common with NextDNS, AdGuard, dnsmasq, Pi-hole client, Cloudflare WARP.
        if scutil --dns 2>/dev/null | awk '/nameserver\[[0-9]+\]/{print $3}' | grep -qE '^(127\.|::1$)'; then
            echo "  !! Local DNS proxy detected in resolver chain (127.x or ::1 nameserver)"
            echo "     Apps using the system resolver may route DNS through it."
            echo "     For PID/process: sudo lsof -nP -iUDP:53"
        fi
        
        # mDNSResponder state
        if pgrep -x mDNSResponder >/dev/null; then
            pid=$(pgrep -x mDNSResponder | head -1)
            pass "mDNSResponder running" "PID $pid"
        else
            fail "mDNSResponder" "not running — system DNS will be broken"
        fi
        
        # ---------------------------------------------------------------------------
        # Time-sync deep-dive: compare local clock to HTTP Date, AND check whether
        # macOS network time sync itself is enabled + which server it's pointing at.
        # Stratum-16 (unsynced) clocks are the silent killer of TLS validation.
        ntp_enabled=$(systemsetup -getusingnetworktime 2>/dev/null | awk -F': ' '{print $2}')
        ntp_server=$(systemsetup -getnetworktimeserver 2>/dev/null | awk -F': ' '{print $2}')
        
        # HTTP Date drift (works without elevated privs, no NTP infra needed)
        remote_date=$(curl -sIA 'net-ops-probe' --max-time 5 https://www.google.com 2>/dev/null | awk -F': ' 'tolower($1)=="date"{print $2; exit}' | tr -d '\r')
        drift_ok=1
        drift_detail=""
        if [[ -n "$remote_date" ]]; then
            remote_epoch=$(date -j -f '%a, %d %b %Y %H:%M:%S %Z' "$remote_date" +%s 2>/dev/null)
            if [[ -n "$remote_epoch" ]]; then
                local_epoch=$(date +%s)
                drift=$(( local_epoch - remote_epoch ))
                abs_drift=${drift#-}
                if [[ "$abs_drift" -lt 300 ]]; then
                    drift_detail="${drift}s vs HTTP Date (within ±5min)"
                else
                    drift_ok=0
                    drift_detail="${drift}s drift — will break TLS cert validation"
                fi
            fi
        fi
        
        # Optional: query the configured NTP server for actual stratum / offset.
        # sntp is built-in on macOS; suppress its noisy output.
        ntp_offset=""
        if [[ -n "$ntp_server" ]] && command -v sntp >/dev/null 2>&1; then
            ntp_offset=$(sntp -t 3 "$ntp_server" 2>/dev/null | awk '/[+-][0-9]+\.[0-9]+/{print $1; exit}')
        fi
        
        combined="$drift_detail"
        [[ -n "$ntp_enabled" ]] && combined="$combined; NTP sync=$ntp_enabled"
        [[ -n "$ntp_server" ]] && combined="$combined; server=$ntp_server"
        [[ -n "$ntp_offset" ]] && combined="$combined; sntp offset=${ntp_offset}s"
        
        if [[ "$drift_ok" -eq 1 ]] && { [[ "$ntp_enabled" == "On" ]] || [[ -z "$ntp_enabled" ]]; }; then
            pass "Time sync" "$combined"
        else
            fail "Time sync" "$combined"
        fi
        
        # MTU / path-MTU discovery test. Standard Ethernet MTU is 1500.
        # We send a 1472-byte payload (1472 + 20 IP + 8 ICMP = 1500) with DF set.
        # If this fails but a smaller size works, there's a path-MTU issue
        # (PPPoE, weird tunnel, broken ICMP "fragmentation needed" delivery).
        if ping -D -s 1472 -c 1 -t 3 1.1.1.1 >/dev/null 2>&1; then
            pass "Path MTU 1500 (1472-byte DF payload)" "to 1.1.1.1"
        else
            if ping -D -s 1400 -c 1 -t 3 1.1.1.1 >/dev/null 2>&1; then
                fail "Path MTU 1500 (1472-byte DF payload)" "1500 fails, 1428+ works — path MTU < 1500 (VPN/PPPoE?)"
            else
                # Both fail — DF blocking entirely; don't flag as MTU
                pass "Path MTU test inconclusive" "ICMP DF blocked or destination unreachable"
            fi
        fi
        
        # IPv6 deep-dive — classifies v6 stack state across four meaningful tiers
        # instead of a binary works/broken. Each tier maps to a distinct fix path.
        v6_state=""
        v6_detail=""
        
        # 1. Any v6 address on a non-loopback interface?
        v6_addrs=$(ifconfig 2>/dev/null | awk '/^[a-z]/{ifn=$1} /inet6 /{print ifn" "$2}' | grep -v "::1\|fe80::" | grep -v "^utun\|^awdl\|^llw\|^bridge")
        # 2. Any GLOBAL v6 address (not ULA fd00::/8)?
        v6_global=$(printf '%s\n' "$v6_addrs" | awk '$2 !~ /^fd/ && $2 !~ /^fc/{print; exit}')
        # 3. Is there an actual global default route?
        v6_default=$(route -n get -inet6 default 2>&1 | awk '/gateway:/{print $2; exit}')
        [[ "$v6_default" =~ ^fe80 ]] && v6_default=""  # link-local doesn't count
        
        if [[ -z "$v6_addrs" ]]; then
            v6_state="disabled"
            v6_detail="no v6 addresses on physical interfaces — IPv6 disabled or unconfigured"
        elif [[ -z "$v6_global" ]]; then
            v6_state="ula_only"
            v6_detail="only ULA (fd00::/8) addresses present — ISP/router not delegating public v6 prefix"
        elif [[ -z "$v6_default" ]]; then
            v6_state="no_route"
            v6_detail="global v6 address present but no default route — RA not received or NDP broken"
        else
            # We have a v6 address and a route — test actual connectivity
            aaaa=$(dig +short +time=2 +tries=1 AAAA "$TEST_HOST" 2>/dev/null | head -1)
            if [[ -n "$aaaa" ]] && curl -6 -sS -o /dev/null --max-time 4 "https://$TEST_HOST" 2>/dev/null; then
                v6_state="healthy"
                v6_detail="global addr + default route + curl -6 works"
            else
                v6_state="path_broken"
                v6_detail="addr=$v6_global, route via $v6_default, but curl -6 fails — upstream v6 path dead"
            fi
        fi
        
        case "$v6_state" in
            disabled|healthy)
                pass "IPv6 stack ($v6_state)" "$v6_detail" ;;
            ula_only)
                fail "IPv6 stack ($v6_state)" "$v6_detail — apps may try v6 first, hit 'no route', fall back to v4 (slow). Fix: sudo networksetup -setv6off <service>" ;;
            no_route)
                fail "IPv6 stack ($v6_state)" "$v6_detail — check ndp -an for RA receipt; restart interface or check router RA config" ;;
            path_broken)
                fail "IPv6 stack ($v6_state)" "$v6_detail — VPN/firewall blocking v6, or ISP black-holing v6 traffic" ;;
        esac
        
        # ---------------------------------------------------------------------------
        section "6. APPLICATION LAYER (real HTTP request)"
        # ---------------------------------------------------------------------------
        for url in "https://www.google.com" "https://github.com"; do
            if out=$(curl -sS -o /dev/null -w "%{http_code} %{size_download}b" --max-time "$TIMEOUT" "$url" 2>&1); then
                pass "GET $url" "$out"
            else
                fail "GET $url" "$out"
            fi
        done
        
        # ---------------------------------------------------------------------------
        if should_run_rung 7; then
        section "7. KNOWN VPN / DNS CLIENT FOOTPRINT"
        # ---------------------------------------------------------------------------
        KNOWN_PATHS=(
            "/Applications/Proton VPN.app"
            "/Applications/Mullvad VPN.app"
            "/Applications/Tailscale.app"
            "/Applications/Cisco/Cisco Secure Client.app"
            "/Applications/Cisco/Cisco AnyConnect Secure Mobility Client.app"
            "/Applications/NordVPN.app"
            "/Applications/NextDNS.app"
            "/Applications/Little Snitch.app"
            "/Applications/Lulu.app"
            "/Library/Application Support/NextDNS"
        )
        for p in "${KNOWN_PATHS[@]}"; do
            [[ -e "$p" ]] && echo "  Installed: $p"
        done
        
        # Browser DoH state — Chrome / Brave / Edge / Firefox have their own resolvers
        # that bypass system DNS entirely when DoH is configured. Useful for explaining
        # "Chrome works but Safari doesn't" type asymmetries.
        browser_findings=""
        chrome_prefs="$HOME/Library/Application Support/Google/Chrome/Default/Preferences"
        brave_prefs="$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences"
        edge_prefs="$HOME/Library/Application Support/Microsoft Edge/Default/Preferences"
        for label_prefs in "Chrome:$chrome_prefs" "Brave:$brave_prefs" "Edge:$edge_prefs"; do
            label="${label_prefs%%:*}"
            prefs="${label_prefs#*:}"
            if [[ -f "$prefs" ]]; then
                # Chromium stores DoH mode under dns_over_https.mode: "off" | "automatic" | "secure"
                mode=$(perl -ne 'if (/"dns_over_https"\s*:\s*\{[^}]*"mode"\s*:\s*"([^"]+)"/) { print "$1\n"; exit }' "$prefs" 2>/dev/null)
                templates=$(perl -ne 'if (/"dns_over_https"\s*:\s*\{[^}]*"templates"\s*:\s*"([^"]+)"/) { print "$1\n"; exit }' "$prefs" 2>/dev/null)
                if [[ -n "$mode" ]]; then
                    browser_findings+="    $label DoH: mode=$mode${templates:+, server=$templates}\n"
                else
                    browser_findings+="    $label installed, DoH: not configured (system DNS)\n"
                fi
            fi
        done
        # Firefox: per-profile prefs.js, network.trr.mode (0=off, 2=enabled w/fallback, 3=enabled only, 5=disabled)
        for fx_prefs in "$HOME/Library/Application Support/Firefox/Profiles"/*.default*/prefs.js; do
            [[ -f "$fx_prefs" ]] || continue
            trr_mode=$(awk -F'"' '/"network.trr.mode"/{print $4; exit}' "$fx_prefs" 2>/dev/null)
            trr_uri=$(awk -F'"' '/"network.trr.uri"/{print $4; exit}' "$fx_prefs" 2>/dev/null)
            case "${trr_mode:-0}" in
                2) state="enabled (with system fallback)" ;;
                3) state="enabled (no fallback)" ;;
                5) state="disabled by policy" ;;
                *) state="off (system DNS)" ;;
            esac
            browser_findings+="    Firefox DoH: $state${trr_uri:+, server=$trr_uri}\n"
            break  # only check one profile
        done
        if [[ -n "$browser_findings" ]]; then
            info "  Browser DoH state (browsers may bypass system DNS):"
            printf '%b' "$browser_findings"
        fi
        
        # Network services often reveal VPN/DNS clients that don't install at /Applications
        # (e.g. CLI-only NextDNS, kernel/system extensions, virtual interfaces)
        ns_pattern='Proton|Mullvad|NextDNS|Cisco|NordVPN|Tailscale|WireGuard|OpenVPN|Cloudflare|WARP|AdGuard'
        ns_found=$(networksetup -listallnetworkservices 2>/dev/null | grep -iE "$ns_pattern" || true)
        if [[ -n "$ns_found" ]]; then
            echo "  Network services:"
            echo "$ns_found" | sed 's/^/    /'
        fi
        
        fi  # end rung 7
        
        # Persist state for future --quick runs (only when we ran the FULL ladder).
        if [[ "$QUICK_MODE" -eq 0 ]]; then
            cache_save_state "$PASS_COUNT" "$FAIL_COUNT" "$FIRST_FAIL"
        fi
        
        emit_summary
        if [[ "$JSON_MODE" -eq 0 ]]; then
            if [[ -n "$FIRST_FAIL" ]]; then
                case "$FIRST_FAIL" in
                    *"LINK LAYER"*)    echo "  Next: check ifconfig / networksetup, fix interface / DHCP" ;;
                    *"SOCKET"*)        echo "  Next: check Little Snitch / Lulu / pfctl rules; AV protocol filtering; consumer router DoH IP blocking" ;;
                    *"ICMP"*|*"IP /"*) echo "  Next: check route table, ISP/upstream connectivity" ;;
                    *"DNS INFRASTRUCTURE"*) echo "  Next: check UDP/53 outbound, router DNS forwarder" ;;
                    *"RESOLVER PATH"*) echo "  Next: bash scripts/macos/dns-audit.sh   # drill rung 5 (the hook layer)" ;;
                    *"APPLICATION"*)   echo "  Next: check proxy (scutil --proxy), keychain certs, IPv6 preference" ;;
                    *) echo "  Next: re-run with --verbose; check references/common-culprits.md" ;;
                esac
            else
                echo "  (No failures. If user still reports issues, see rung 7 footprint and time-based notes in references/diagnostic-ladder.md.)"
            fi
            echo
            echo "=== END PROBE ==="
        fi
        
      • resolver-clean.sh 3.5 KB
        #!/usr/bin/env bash
        # net-ops :: macos/resolver-clean.sh
        # Safely remove orphaned /etc/resolver/* files left behind by disconnected VPNs.
        # NEVER removes Tailscale or current-VPN-tunnel entries.
        #
        # Defaults to DRY RUN — pass --apply to actually delete.
        # Requires sudo.
        
        set -eu
        # Shared terminal toolkit (skills/_lib/term.sh) — colorized, ASCII-aware section
        # headers. Dump/fixer output isn't a checklist, so it uses the bare-header style
        # (a deliberate exception per docs/TERMINAL-DESIGN.md), not the enclosing panel.
        __nlib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
        if [ -n "${__nlib:-}" ] && [ -f "$__nlib/term.sh" ]; then . "$__nlib/term.sh"; term_init
        else term_header() { printf '== %s ==\n' "${1:-}"; }; fi
        
        APPLY=0
        PROTECT_PATTERNS="${PROTECT_PATTERNS:-100\.100\.100\.100}"
        
        for arg in "$@"; do
            case "$arg" in
                --apply) APPLY=1 ;;
                --protect=*) PROTECT_PATTERNS="${arg#--protect=}" ;;
                --help|-h)
                    cat <<EOF
        Usage: $0 [--apply] [--protect=REGEX]
        
          --apply              Actually delete (default: dry-run only)
          --protect=REGEX      Nameserver pattern to protect (default: Tailscale's 100.100.100.100)
        
        Examples:
          $0                                  # show what would be removed
          $0 --apply                          # remove orphan resolvers, protecting Tailscale
          $0 --apply --protect='100\\.\\.|192\\.168\\.1\\.'  # also protect 192.168.1.x
        EOF
                    exit 0 ;;
            esac
        done
        
        if [[ ! -d /etc/resolver ]] || [[ -z "$(ls -A /etc/resolver 2>/dev/null)" ]]; then
            echo "/etc/resolver/ is empty. Nothing to do."
            exit 0
        fi
        
        term_header "BEFORE"
        for f in /etc/resolver/*; do
            [[ -f "$f" ]] || continue
            ns=$(awk '/^nameserver/{print $2}' "$f" | tr '\n' ',')
            echo "  $f -> ${ns%,}"
        done
        
        TARGETS=()
        for f in /etc/resolver/*; do
            [[ -f "$f" ]] || continue
            if awk '/^nameserver/{print $2}' "$f" | grep -qE "$PROTECT_PATTERNS"; then
                continue
            fi
            TARGETS+=("$f")
        done
        
        if [[ "${#TARGETS[@]}" -eq 0 ]]; then
            echo
            echo "No orphan resolver files (all match protected nameserver pattern). Nothing to clean."
            exit 0
        fi
        
        echo
        term_header "TARGETS FOR REMOVAL"
        for f in "${TARGETS[@]}"; do
            echo "  $f"
        done
        
        if [[ "$APPLY" -eq 0 ]]; then
            echo
            echo "DRY RUN — pass --apply to actually remove the files above."
            exit 0
        fi
        
        # Apply
        if [[ "$EUID" -ne 0 ]]; then
            echo "Need root. Re-running with sudo..."
            exec sudo "$0" --apply --protect="$PROTECT_PATTERNS"
        fi
        
        echo
        term_header "REMOVING"
        for f in "${TARGETS[@]}"; do
            if rm -f "$f"; then
                echo "[OK]   $f"
            else
                echo "[FAIL] $f"
            fi
        done
        
        echo
        term_header "FLUSHING DNS CACHE"
        dscacheutil -flushcache
        killall -HUP mDNSResponder 2>/dev/null || true
        echo "  done."
        
        echo
        term_header "VERIFICATION"
        if out=$(dscacheutil -q host -a name google.com 2>&1) && echo "$out" | grep -q "ip_address:"; then
            addr=$(echo "$out" | awk '/ip_address:/{print $2; exit}')
            echo "[PASS] dscacheutil google.com -> $addr"
        else
            echo "[FAIL] dscacheutil still broken. Drill into scutil --dns and configuration profiles."
        fi
        
        if curl -sS -o /dev/null -w "[PASS] HTTPS google.com -> HTTP %{http_code}\n" --max-time 8 https://www.google.com 2>&1; then
            :
        else
            echo "[FAIL] HTTPS still broken."
        fi
        
        echo
        term_header "AFTER"
        if [[ -n "$(ls -A /etc/resolver 2>/dev/null)" ]]; then
            for f in /etc/resolver/*; do
                [[ -f "$f" ]] || continue
                ns=$(awk '/^nameserver/{print $2}' "$f" | tr '\n' ',')
                echo "  $f -> ${ns%,}"
            done
        else
            echo "  /etc/resolver/ is now empty."
        fi
        
    • windows
      • nextdns-audit.ps1 14.6 KB · in bundle
      • nextdns-boot-fix.ps1 11 KB · in bundle
      • nextdns-doh-setup.ps1 33.3 KB · in bundle
      • nrpt-audit.ps1 3.1 KB · in bundle
      • nrpt-clean.ps1 2.8 KB · in bundle
      • probe.ps1 10.7 KB · in bundle
      • smb-audit.ps1 21.9 KB · in bundle
    • _lib
      • cache.sh 1.7 KB
        # net-ops :: _lib/cache.sh
        # Lightweight state cache for --quick mode.
        # Stores the last probe's summary so we can decide whether to run the full
        # ladder or just the most failure-prone layers (rungs 5+6).
        
        CACHE_DIR="${NETOPS_CACHE_DIR:-${TMPDIR:-/tmp}/net-ops}"
        CACHE_FILE="$CACHE_DIR/last-state.json"
        CACHE_MAX_AGE_SECONDS="${NETOPS_CACHE_MAX_AGE:-600}"  # 10 minutes default
        
        QUICK_MODE=0
        
        parse_quick_flag() {
            for a in "$@"; do
                [[ "$a" == "--quick" ]] && QUICK_MODE=1
            done
        }
        
        # Returns 0 if the last cached state is "all healthy and recent" — caller
        # should then skip rungs 1-4 and only run 5+6. Returns 1 otherwise.
        cache_indicates_healthy() {
            [[ "$QUICK_MODE" -eq 1 ]] || return 1
            [[ -f "$CACHE_FILE" ]] || return 1
            # File age check (BSD vs GNU stat compat)
            local mtime now age
            mtime=$(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null)
            [[ -n "$mtime" ]] || return 1
            now=$(date +%s)
            age=$(( now - mtime ))
            (( age > CACHE_MAX_AGE_SECONDS )) && return 1
            # Parse: only return healthy if fail count was zero
            grep -q '"fail":0' "$CACHE_FILE" 2>/dev/null
        }
        
        # Save current state at end of probe.
        cache_save_state() {
            local pass="$1" fail="$2" first_fail="$3"
            mkdir -p "$CACHE_DIR" 2>/dev/null || return 0
            printf '{"ts":%d,"pass":%d,"fail":%d,"first_fail":%q}\n' \
                "$(date +%s)" "$pass" "$fail" "$first_fail" > "$CACHE_FILE" 2>/dev/null || true
        }
        
        # Predicate the probe can call to decide whether to run a given rung.
        # Args: rung_number (1..7). In quick mode, only 5 and 6 run.
        should_run_rung() {
            local rung="$1"
            if cache_indicates_healthy; then
                case "$rung" in
                    5|6) return 0 ;;
                    *)   return 1 ;;
                esac
            fi
            return 0
        }
        
      • output.sh 5.1 KB
        # net-ops :: _lib/output.sh
        # Output mode handling for probe scripts. Three renderings of the same stream:
        #   - panel (default at a TTY): the term.sh enclosing panel — section sub-headers
        #     on the │ rail, colored term_mark check rows, a footer health indicator.
        #   - text (piped / non-TTY): legacy [PASS]/[FAIL] lines + a SUMMARY block. Kept
        #     byte-stable because humans AND LLMs scan it for the first [FAIL]; tests and
        #     pipes depend on it.
        #   - json (--json): newline-delimited JSON, one record per check + a summary.
        #
        # The panel is the human default (per docs/TERMINAL-DESIGN.md); it never touches
        # the piped/--json data product, so stream behaviour is unchanged for consumers.
        #
        # Usage in a probe script:
        #   source "$(dirname "$0")/../_lib/output.sh"
        #   PANEL_TITLE="net · linux probe"     # optional; titles the panel header
        #   parse_output_flags "$@"
        #   # then use section / pass / fail / info / emit_summary as before
        
        JSON_MODE="${JSON_MODE:-0}"
        
        # Shared terminal toolkit (skills/_lib/term.sh) for the panel rendering. Absent ->
        # the legacy text path is used, so this degrades cleanly with no behaviour change.
        __net_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../_lib" 2>/dev/null && pwd || true)"
        if [ -n "${__net_lib:-}" ] && [ -f "$__net_lib/term.sh" ]; then
            . "$__net_lib/term.sh"
            __NET_HAVE_TERM=1
        else
            __NET_HAVE_TERM=0
        fi
        
        # Panel header title — a probe script may override before the first section().
        PANEL_TITLE="${PANEL_TITLE:-net-ops}"
        __PANEL_OPEN=0
        
        parse_output_flags() {
            for a in "$@"; do
                [[ "$a" == "--json" ]] && JSON_MODE=1
            done
        }
        
        # Panel applies in text mode only, when stdout is a TTY (or FORCE_COLOR forces a
        # render for verification). Piped/non-TTY text consumers get the legacy format.
        _panel_active() {
            [[ "$JSON_MODE" -eq 1 || "$__NET_HAVE_TERM" -eq 0 ]] && return 1
            [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]
        }
        
        # Lazily open the panel frame (term_init + header + a breath row) on first use.
        _panel_open() {
            [[ "$__PANEL_OPEN" -eq 1 ]] && return 0
            term_init
            term_panel_open net-ops "$PANEL_TITLE"
            term_panel_vert
            __PANEL_OPEN=1
        }
        
        # JSON-safe string escaper. Handles backslash, double-quote, and control chars.
        _json_escape() {
            local s="$1"
            s="${s//\\/\\\\}"
            s="${s//\"/\\\"}"
            s="${s//$'\n'/\\n}"
            s="${s//$'\r'/\\r}"
            s="${s//$'\t'/\\t}"
            printf '%s' "$s"
        }
        
        # These are the public API. They route to panel / text / JSON per mode.
        PASS_COUNT=0
        FAIL_COUNT=0
        FIRST_FAIL=""
        CURRENT_SECTION=""
        
        section() {
            CURRENT_SECTION="$1"
            if [[ "$JSON_MODE" -eq 1 ]]; then
                printf '{"type":"section","name":"%s"}\n' "$(_json_escape "$1")"
            elif _panel_active; then
                _panel_open
                term_panel_vert
                term_panel_line "$(term_color cyan "$1")"
            else
                echo
                echo "=== $1 ==="
            fi
        }
        
        pass() {
            PASS_COUNT=$((PASS_COUNT + 1))
            if [[ "$JSON_MODE" -eq 1 ]]; then
                printf '{"type":"check","section":"%s","label":"%s","status":"pass","detail":"%s"}\n' \
                    "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
            elif _panel_active; then
                _panel_open
                term_status_row ok "$1" "${2:-}"
            else
                echo "[PASS] $1${2:+ :: $2}"
            fi
        }
        
        fail() {
            FAIL_COUNT=$((FAIL_COUNT + 1))
            [[ -z "$FIRST_FAIL" ]] && FIRST_FAIL="[$CURRENT_SECTION] $1"
            if [[ "$JSON_MODE" -eq 1 ]]; then
                printf '{"type":"check","section":"%s","label":"%s","status":"fail","detail":"%s"}\n' \
                    "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
            elif _panel_active; then
                _panel_open
                term_status_row bad "$1" "${2:-}"
            else
                echo "[FAIL] $1${2:+ :: $2}"
            fi
        }
        
        # Call from end of probe to emit summary record / block / panel footer.
        emit_summary() {
            if [[ "$JSON_MODE" -eq 1 ]]; then
                printf '{"type":"summary","pass":%d,"fail":%d,"first_fail":"%s"}\n' \
                    "$PASS_COUNT" "$FAIL_COUNT" "$(_json_escape "$FIRST_FAIL")"
            elif _panel_active; then
                _panel_open
                [[ -n "$FIRST_FAIL" ]] && { term_panel_vert; term_panel_line "$(term_color dim "first fail: $FIRST_FAIL")"; }
                term_panel_vert
                local state="healthy" health="$PASS_COUNT ok"
                if [[ "$FAIL_COUNT" -gt 0 ]]; then
                    state="critical"; health="$FAIL_COUNT fail ${TERM_DOT} $PASS_COUNT ok"
                fi
                term_panel_close "--json for data ${TERM_DOT} --redact to mask" "$(term_health "$state" "$health")"
            else
                echo
                echo "=== SUMMARY ==="
                echo "  PASS: $PASS_COUNT    FAIL: $FAIL_COUNT"
                if [[ -n "$FIRST_FAIL" ]]; then
                    echo "  First failure: $FIRST_FAIL"
                else
                    echo "  No failures."
                fi
            fi
        }
        
        # Helper for scripts that want to suppress informational/diagnostic output
        # (the non-PASS/FAIL annotations like scutil dumps) in JSON mode.
        info() {
            if [[ "$JSON_MODE" -eq 1 ]]; then
                # Optional: emit info records. Keep silent for cleaner JSON parsing.
                return 0
            elif _panel_active; then
                term_panel_line "$(term_color dim "$*")"
            else
                echo "$@"
            fi
        }
        
      • redact.sh 2.6 KB
        # net-ops :: _lib/redact.sh
        # Shared opsec redaction for diagnostic output. Source from any bash script:
        #
        #   source "$(dirname "$0")/../_lib/redact.sh"
        #   parse_redact_flag "$@"
        #   maybe_redact_self "$@"    # re-invokes self without --redact if flag set
        #
        # Public IPs (1.1.1.1, 8.8.8.8, Tailscale 100.100.100.100 anchor) are
        # preserved — they're diagnostic landmarks. Private/CGNAT/link-local
        # ranges, MACs, and *.ts.net tailnet names are masked.
        
        REDACT="${REDACT:-0}"
        
        parse_redact_flag() {
            for a in "$@"; do
                [[ "$a" == "--redact" ]] && REDACT=1
            done
        }
        
        redact_filter() {
            if [[ "${REDACT:-0}" -eq 0 ]]; then cat; return; fi
            perl -pe '
                # Preserve well-known anchors first
                s/100\.100\.100\.100/__TS_MAGIC__/g;
                # Redact private / CGNAT / link-local IPv4
                s/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/10.X.X.X/g;
                s/\b172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}\b/172.X.X.X/g;
                s/\b192\.168\.\d{1,3}\.\d{1,3}\b/192.168.X.X/g;
                s/\b100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b/100.X.X.X/g;
                s/\b169\.254\.\d{1,3}\.\d{1,3}\b/169.254.X.X/g;
                # MAC addresses (both : and - separators)
                s/\b[0-9a-fA-F]{2}([:-])[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\b/XX:XX:XX:XX:XX:XX/g;
                # Tailscale tailnet names
                s/\b[a-z0-9-]+\.ts\.net\b/REDACTED.ts.net/g;
                # Restore anchors
                s/__TS_MAGIC__/100.100.100.100/g;
            '
        }
        
        # Helper: self-reinvoke and pipe through post-processing filters when needed.
        # Handles both --redact (mask private addrs) and --json (drop non-JSON chatter).
        # Avoids bash 3.2 exec-redirect quirks via single-level subprocess.
        maybe_redact_self() {
            # Only reinvoke if at least one filter is active
            [[ "${REDACT:-0}" -eq 1 ]] || [[ "${JSON_MODE:-0}" -eq 1 ]] || return 0
            # Prevent infinite recursion
            [[ "${_NETOPS_POSTPROCESSED:-0}" -eq 1 ]] && return 0
            export _NETOPS_POSTPROCESSED=1
        
            # Strip --redact from args (child runs without it to avoid double-recursion).
            # --json is preserved so JSON_MODE stays set in the child for any code that
            # changes behavior in JSON mode (e.g. info() suppression in output.sh).
            local cleaned_args=()
            for a in "$@"; do [[ "$a" != "--redact" ]] && cleaned_args+=("$a"); done
        
            if [[ "${JSON_MODE:-0}" -eq 1 ]] && [[ "${REDACT:-0}" -eq 1 ]]; then
                "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | grep '^{' | redact_filter
            elif [[ "${JSON_MODE:-0}" -eq 1 ]]; then
                "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | grep '^{'
            else
                "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | redact_filter
            fi
            exit "${PIPESTATUS[0]}"
        }
        
    • probe 2.2 KB · in bundle
    • reverse-probe.sh 3.7 KB
      #!/usr/bin/env bash
      # net-ops :: reverse-probe.sh
      # Diagnose a TARGET host from OUTSIDE — useful when the local probe on the
      # target says "all good" but external services / users still report problems.
      # Runs from this machine against a target host you can reach (LAN, tailnet,
      # public IP, etc).
      #
      # Usage:
      #   scripts/reverse-probe.sh <host>           # use default ports/checks
      #   scripts/reverse-probe.sh <host> [port...] # add custom TCP ports to probe
      #
      # Examples:
      #   scripts/reverse-probe.sh example.local
      #   scripts/reverse-probe.sh 100.84.X.X 8080 5432
      #   scripts/reverse-probe.sh api.mycompany.com 443
      
      set -u
      
      TARGET="${1:-}"
      if [[ -z "$TARGET" ]]; then
          echo "Usage: $0 <host> [extra_tcp_port ...]" >&2
          exit 1
      fi
      shift
      EXTRA_PORTS=("$@")
      DEFAULT_PORTS=(22 80 443)
      TIMEOUT=4
      
      # shellcheck source=_lib/redact.sh
      source "$(dirname "$0")/_lib/redact.sh"
      # shellcheck source=_lib/output.sh
      source "$(dirname "$0")/_lib/output.sh"
      PANEL_TITLE="reverse probe"
      parse_redact_flag "$@"
      parse_output_flags "$@"
      maybe_redact_self "$TARGET" "$@"
      
      # Resolve target — separates DNS issues from reachability issues
      section "1. NAME RESOLUTION FROM HERE"
      if [[ "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
          pass "Target is literal IP" "$TARGET"
          TARGET_IP="$TARGET"
      else
          resolved=$(dig +short +time=3 +tries=1 "$TARGET" 2>/dev/null | head -1)
          if [[ -n "$resolved" ]]; then
              pass "Resolved $TARGET (dig, bypass resolver)" "$resolved"
              TARGET_IP="$resolved"
          else
              fail "Resolved $TARGET" "no answer from local DNS — can't proceed past name layer"
              emit_summary
              exit 1
          fi
      fi
      
      section "2. ICMP REACHABILITY"
      if ping -c 2 -W $((TIMEOUT * 1000)) "$TARGET_IP" >/dev/null 2>&1; then
          pass "Ping $TARGET_IP"
      else
          fail "Ping $TARGET_IP" "no ICMP response (or ICMP filtered)"
      fi
      
      section "3. TCP PORT REACHABILITY"
      # De-duplicate ports — extras may overlap defaults
      all_ports=$(printf '%s\n' "${DEFAULT_PORTS[@]}" ${EXTRA_PORTS[@]+"${EXTRA_PORTS[@]}"} | awk '!seen[$0]++')
      while read -r port; do
          [[ -z "$port" ]] && continue
          if nc -zv -G "$TIMEOUT" "$TARGET_IP" "$port" >/dev/null 2>&1; then
              pass "TCP/$port -> $TARGET_IP" "open"
          else
              fail "TCP/$port -> $TARGET_IP" "closed or filtered"
          fi
      done <<< "$all_ports"
      
      section "4. TLS / HTTPS HEALTH (if 443 open)"
      if nc -zv -G "$TIMEOUT" "$TARGET_IP" 443 >/dev/null 2>&1; then
          if [[ "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
              # Connect by IP; cert check will fail SNI but we can still probe
              out=$(curl -sS -o /dev/null -w "%{http_code}|%{time_total}" --max-time "$TIMEOUT" -k "https://$TARGET_IP" 2>&1)
              pass "HTTPS to IP (cert SNI may not match)" "$out"
          else
              out=$(curl -sS -o /dev/null -w "%{http_code}|%{time_total}" --max-time "$TIMEOUT" "https://$TARGET" 2>&1)
              if [[ "$out" =~ ^[0-9]+\|[0-9.]+$ ]]; then
                  pass "HTTPS to $TARGET" "$out"
              else
                  fail "HTTPS to $TARGET" "$out"
              fi
          fi
      fi
      
      section "5. PATH / ROUTING"
      case "$(uname -s)" in
          Darwin)
              # macOS traceroute: -w timeout (sec), -m max hops, -q probes per hop
              info "  traceroute (first 8 hops):"
              traceroute -n -w 2 -q 1 -m 8 "$TARGET_IP" 2>/dev/null | head -10 | sed 's/^/    /' || true
              ;;
          Linux)
              if command -v traceroute >/dev/null 2>&1; then
                  info "  traceroute (first 8 hops):"
                  traceroute -n -w 2 -q 1 -m 8 "$TARGET_IP" 2>/dev/null | head -10 | sed 's/^/    /' || true
              elif command -v mtr >/dev/null 2>&1; then
                  info "  mtr report (5 cycles):"
                  mtr -nrc 5 "$TARGET_IP" 2>/dev/null | tail -10 | sed 's/^/    /' || true
              fi
              ;;
      esac
      
      emit_summary
      
    • ssh-bootstrap.sh 3.6 KB
      #!/usr/bin/env bash
      # net-ops :: ssh-bootstrap.sh
      # Establish an SSH session to any target (Windows / macOS / Linux) using
      # password auth via sshpass. Reads password from stdin so it never appears
      # in argv / shell history. Auto-detects target OS and emits the right
      # invocation pattern for follow-up commands.
      #
      # Usage:
      #   echo 'password' | scripts/ssh-bootstrap.sh user@host
      #   scripts/ssh-bootstrap.sh user@host    # interactive prompt
      
      set -euo pipefail
      
      TARGET="${1:-}"
      if [[ -z "$TARGET" ]]; then
          echo "Usage: $0 user@host" >&2
          exit 1
      fi
      
      if ! command -v sshpass >/dev/null 2>&1; then
          echo "sshpass not found. Install:" >&2
          echo "  macOS:   brew install hudochenkov/sshpass/sshpass" >&2
          echo "  Linux:   apt install sshpass / dnf install sshpass" >&2
          exit 1
      fi
      
      # Read password — from stdin if piped, else prompt
      if [[ -t 0 ]]; then
          read -rsp "Password for $TARGET: " PASSWORD
          echo
      else
          read -r PASSWORD
      fi
      export SSHPASS="$PASSWORD"
      
      # Quick connectivity check (also accepts host key on first contact).
      # Use a probe that works on all three: `uname -s` on Unix, fails on cmd.exe
      # but succeeds on Windows OpenSSH default shell when it's pwsh/powershell.
      echo "Probing $TARGET ..."
      PROBE=$(sshpass -e ssh \
          -o StrictHostKeyChecking=accept-new \
          -o ConnectTimeout=10 \
          "$TARGET" \
          'uname -s 2>/dev/null || cmd /c ver 2>nul || ver' 2>&1 | tr -d '\r')
      
      echo "  Response: $(echo "$PROBE" | head -3 | tr '\n' ' | ')"
      
      # Detect OS family from probe output
      OS=""
      case "$PROBE" in
          *Darwin*)         OS="macos" ;;
          *Linux*)          OS="linux" ;;
          *Microsoft*|*Windows*) OS="windows" ;;
      esac
      
      if [[ -z "$OS" ]]; then
          echo
          echo "Could not auto-detect OS. Treating as unknown — defaulting to bash transport."
          OS="unknown"
      fi
      
      echo "Detected OS family: $OS"
      
      # Per-OS smoke test
      case "$OS" in
          windows)
              echo
              echo "Testing PowerShell -EncodedCommand transport ..."
              TEST_PS='Write-Output ("PS ready :: " + $PSVersionTable.PSVersion.ToString())'
              B64=$(printf '%s' "$TEST_PS" | iconv -t UTF-16LE | base64)
              sshpass -e ssh "$TARGET" "powershell -NoProfile -EncodedCommand $B64" 2>&1 | tail -3
              ;;
          macos|linux|unknown)
              echo
              echo "Testing bash transport ..."
              sshpass -e ssh "$TARGET" 'bash -c "echo BASH_OK :: \$(bash --version | head -1)"' 2>&1 | tail -2
              ;;
      esac
      
      # Per-OS invocation hints
      echo
      echo "---"
      case "$OS" in
          windows)
              cat <<EOF
      Ready (Windows target). Run a PowerShell script via:
      
        PS_SCRIPT=\$(cat skills/net-ops/scripts/windows/probe.ps1)
        B64=\$(printf '%s' "\$PS_SCRIPT" | iconv -t UTF-16LE | base64)
        SSHPASS='<password>' sshpass -e ssh $TARGET "powershell -NoProfile -EncodedCommand \$B64"
      
      Drilldown scripts: nrpt-audit.ps1, nrpt-clean.ps1
      
      For zero-friction follow-up, install your pubkey on the target:
        Windows admin path: %ProgramData%\\ssh\\administrators_authorized_keys
        Windows user path:  %USERPROFILE%\\.ssh\\authorized_keys
      EOF
              ;;
          macos)
              cat <<EOF
      Ready (macOS target). Run a bash script via:
      
        SSHPASS='<password>' sshpass -e ssh $TARGET 'bash -s' < skills/net-ops/scripts/macos/probe.sh
      
      Drilldown scripts: macos/dns-audit.sh, macos/resolver-clean.sh
      
      Persistent access: ssh-copy-id $TARGET
      EOF
              ;;
          linux)
              cat <<EOF
      Ready (Linux target). Run a bash script via:
      
        SSHPASS='<password>' sshpass -e ssh $TARGET 'bash -s' < skills/net-ops/scripts/linux/probe.sh
      
      Drilldown scripts: linux/dns-audit.sh, linux/resolved-reset.sh
      
      Persistent access: ssh-copy-id $TARGET
      EOF
              ;;
          *)
              echo "Generic SSH ready. Run commands directly."
              ;;
      esac
      
  • tests
    • run.sh 20.8 KB
      #!/usr/bin/env bash
      # net-ops :: tests/run.sh
      # Lightweight self-tests. Run from the repo root:
      #   bash skills/net-ops/tests/run.sh
      #
      # These verify structural and output invariants of the probe scripts WITHOUT
      # trying to simulate broken network state. They catch regressions in:
      #  - bash syntax / unbound vars / set -u trips
      #  - section labels and ordering
      #  - --redact actually masking private addrs / tailnet names
      #  - --json producing parseable NDJSON
      #  - summary block format
      #  - dispatcher routing to the right per-OS script
      
      set -u
      
      PASS=0
      FAIL=0
      FAILED_TESTS=()
      
      assert() {
          local name="$1"; shift
          if "$@"; then
              PASS=$((PASS+1))
              printf "  [PASS] %s\n" "$name"
          else
              FAIL=$((FAIL+1))
              FAILED_TESTS+=("$name")
              printf "  [FAIL] %s\n" "$name"
          fi
      }
      
      contains() { local hay="$1" needle="$2"; [[ "$hay" == *"$needle"* ]]; }
      not_contains() { local hay="$1" needle="$2"; [[ "$hay" != *"$needle"* ]]; }
      
      # Locate skill root regardless of invocation dir
      here="$(cd "$(dirname "$0")" && pwd)"
      root="$(cd "$here/.." && pwd)"
      
      echo "=== net-ops self-tests ==="
      echo "Root: $root"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- terminal design system (term.sh panel adoption) ---"
      # ---------------------------------------------------------------------------
      # OS-independent: drives _lib/output.sh directly (no live probe), so it runs on
      # every platform including Windows where the probe ladder is skipped below.
      OUTLIB="$root/scripts/_lib/output.sh"
      TERMLIB="$root/../_lib/term.sh"
      
      assert "output.sh sources shared term.sh" contains "$(cat "$OUTLIB")" '_lib/term.sh'
      assert "probe scripts set a PANEL_TITLE" \
          bash -c 'grep -q "PANEL_TITLE=" "$1"' _ "$root/scripts/linux/probe.sh"
      
      # Exercise the public output API in one of the three modes.
      _drive() {
          bash -c '
              OUT="$1"; shift
              . "$OUT"
              PANEL_TITLE="linux probe"
              parse_output_flags "$@"
              section "1. LINK LAYER"; pass "iface up" "eth0"; fail "carrier" "no link"
              emit_summary
          ' _ "$OUTLIB" "$@"
      }
      
      # Panel path (FORCE_COLOR forces the render): the enclosing frame appears and is
      # pure ASCII under TERM_ASCII=1.
      panel_ascii="$(TERM_ASCII=1 FORCE_COLOR=1 _drive 2>/dev/null)"
      assert "panel renders the enclosing frame" contains "$panel_ascii" "+-- "
      assert "panel footer carries a health indicator" contains "$panel_ascii" "fail"
      assert "panel is pure ASCII under TERM_ASCII=1" \
          bash -c '! printf "%s" "$1" | LC_ALL=C grep -q "[^[:print:][:cntrl:]]"' _ "$panel_ascii"
      
      # Legacy text path (piped / non-TTY): the greppable [PASS]/[FAIL]/SUMMARY contract
      # is byte-stable, so humans, LLMs, tests, and the --watch dispatcher keep working.
      legacy="$(_drive 2>/dev/null)"
      assert "piped text keeps [PASS] anchor" contains "$legacy" "[PASS]"
      assert "piped text keeps [FAIL] anchor" contains "$legacy" "[FAIL]"
      assert "piped text keeps SUMMARY block" contains "$legacy" "=== SUMMARY ==="
      assert "piped text carries no ANSI" not_contains "$legacy" $'\033'
      
      # JSON unaffected by the panel.
      js="$(_drive --json 2>/dev/null)"
      assert "json mode still emits a summary record" contains "$js" '"type":"summary"'
      assert "json mode carries no panel chrome" not_contains "$js" "+-- "
      
      # term.sh primitives are pure ASCII under TERM_ASCII=1.
      if [[ -f "$TERMLIB" ]]; then
          prim="$(TERM_ASCII=1 LT="$TERMLIB" bash -c '. "$LT"; term_init; printf "%s%s%s%s" \
              "$(term_mark ok)" "$(term_status_row ok a b)" "$(term_panel_open net-ops x)" "$TERM_DOT"')"
          assert "term.sh primitives pure ASCII under TERM_ASCII=1" \
              bash -c '! printf "%s" "$1" | LC_ALL=C grep -q "[^[:print:][:cntrl:]]"' _ "$prim"
      fi
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- smb-audit.ps1 structural tests (OS-independent, static) ---"
      # ---------------------------------------------------------------------------
      # Windows-only at runtime, so on mac/linux we assert the contract statically:
      # comment-block help, -Json mode, semantic exit codes, and the guard rails
      # the SKILL text promises (live-VPN vs orphan distinction, credential check).
      SMB="$root/scripts/windows/smb-audit.ps1"
      smb_src="$(cat "$SMB")"
      assert "smb-audit has comment-based help with EXAMPLEs" \
          bash -c 'grep -q "^\.SYNOPSIS" <<<"$0" && grep -qc "^\.EXAMPLE" <<<"$0"' "$smb_src"
      assert "smb-audit documents exit codes incl. domain signal 10" \
          bash -c 'grep -q "10 audit ran and found" <<<"$0"' "$smb_src"
      assert "smb-audit ships -Json with schema id" \
          contains "$smb_src" 'claude-mods.net-ops.smb-audit/v1'
      assert "smb-audit checks EFFECTIVE NRPT policy" \
          contains "$smb_src" 'Get-DnsClientNrptPolicy -Effective'
      assert "smb-audit distinguishes live VPN from orphan rule" \
          contains "$smb_src" 'ORPHANED'
      assert "smb-audit inspects credential targets via cmdkey" \
          contains "$smb_src" 'cmdkey /list'
      assert "smb-audit probes TCP/445" \
          contains "$smb_src" '445'
      assert "smb-audit warns about misleading Resolve-DnsName single-label error" \
          contains "$smb_src" 'volume label syntax'
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- nextdns-audit.ps1 / nextdns-boot-fix.ps1 structural tests (OS-independent) ---"
      # ---------------------------------------------------------------------------
      # Windows-only at runtime. Statically assert the contract AND the load-bearing
      # doctrine, because the whole value of these two scripts is that they stop the
      # next reader chasing the two false leads that cost the original investigation
      # an hour each: adapter DNS, and port-53 ownership.
      NDA="$root/scripts/windows/nextdns-audit.ps1"
      NDF="$root/scripts/windows/nextdns-boot-fix.ps1"
      assert "nextdns-audit exists"    test -f "$NDA"
      assert "nextdns-boot-fix exists" test -f "$NDF"
      nda_src="$(cat "$NDA")"
      ndf_src="$(cat "$NDF")"
      
      assert "nextdns-audit has comment-based help with EXAMPLEs" \
          bash -c 'grep -q "^\.SYNOPSIS" <<<"$0" && grep -q "^\.EXAMPLE" <<<"$0"' "$nda_src"
      assert "nextdns-audit documents exit codes incl. domain signal 10" \
          bash -c 'grep -q "10 audit ran and found" <<<"$0"' "$nda_src"
      assert "nextdns-audit ships -Json with schema id" \
          contains "$nda_src" 'claude-mods.net-ops.nextdns-audit/v1'
      assert "nextdns-audit uses test.nextdns.io as ground truth (not adapter config)" \
          contains "$nda_src" 'test.nextdns.io'
      assert "nextdns-audit keys the verdict on clientName" \
          contains "$nda_src" 'nextdns-windows'
      assert "nextdns-audit labels adapter DNS a red herring" \
          contains "$nda_src" 'RED HERRING'
      assert "nextdns-audit states NextDNS never binds port 53" \
          contains "$nda_src" 'never binds 53'
      assert "nextdns-audit inspects the per-user config scope" \
          contains "$nda_src" 'user.config'
      assert "nextdns-audit checks for absent machine-wide config" \
          contains "$nda_src" 'HKLM:\SOFTWARE\NextDNS'
      assert "nextdns-audit measures the boot->logon exposure window" \
          contains "$nda_src" 'exposureWindowSec'
      assert "nextdns-audit warns that DelayedAutostart is the wrong lever" \
          contains "$nda_src" 'LENGTHENS'
      assert "nextdns-audit offers -SkipNetwork for no-egress boxes" \
          contains "$nda_src" 'SkipNetwork'
      
      assert "nextdns-boot-fix has comment-based help with EXAMPLEs" \
          bash -c 'grep -q "^\.SYNOPSIS" <<<"$0" && grep -q "^\.EXAMPLE" <<<"$0"' "$ndf_src"
      assert "nextdns-boot-fix documents exit codes incl. pending signal 10" \
          bash -c 'grep -q "10 dry run" <<<"$0"' "$ndf_src"
      assert "nextdns-boot-fix defaults to dry run (-Apply gates writes)" \
          contains "$ndf_src" 'DRY RUN'
      assert "nextdns-boot-fix supports -Remove" \
          contains "$ndf_src" '$Remove'
      assert "nextdns-boot-fix registers a logon-triggered task" \
          contains "$ndf_src" 'New-ScheduledTaskTrigger -AtLogOn'
      assert "nextdns-boot-fix uses the correct settings cmdlet name" \
          contains "$ndf_src" 'New-ScheduledTaskSettingsSet'
      assert "nextdns-boot-fix runs unelevated (LeastPrivilege / Limited)" \
          contains "$ndf_src" 'Limited'
      assert "nextdns-boot-fix installs outside the repo (worktrees are disposable)" \
          contains "$ndf_src" 'LOCALAPPDATA'
      assert "nextdns-boot-fix waits for interception before flushing" \
          contains "$ndf_src" 'interception confirmed'
      assert "nextdns-boot-fix flushes via Clear-DnsClientCache with ipconfig fallback" \
          bash -c 'grep -q "Clear-DnsClientCache" <<<"$0" && grep -q "ipconfig /flushdns" <<<"$0"' "$ndf_src"
      assert "nextdns-boot-fix records WHY delayed start is rejected" \
          contains "$ndf_src" 'WRONG DIRECTION'
      
      # --- nextdns-doh-setup.ps1: the machine-scope alternative ---
      NDD="$root/scripts/windows/nextdns-doh-setup.ps1"
      assert "nextdns-doh-setup exists" test -f "$NDD"
      ndd_src="$(cat "$NDD")"
      assert "nextdns-doh-setup has comment-based help with EXAMPLEs" \
          bash -c 'grep -q "^\.SYNOPSIS" <<<"$0" && grep -q "^\.EXAMPLE" <<<"$0"' "$ndd_src"
      assert "nextdns-doh-setup documents exit codes" \
          bash -c 'grep -q "10 dry run" <<<"$0"' "$ndd_src"
      assert "nextdns-doh-setup defaults to dry run" \
          contains "$ndd_src" 'DRY RUN'
      assert "nextdns-doh-setup ships a -Rollback path" \
          contains "$ndd_src" '$Rollback'
      assert "nextdns-doh-setup ships a -VerifyOnly path" \
          contains "$ndd_src" '$VerifyOnly'
      assert "nextdns-doh-setup refuses to apply unelevated" \
          contains "$ndd_src" 'needs an ELEVATED PowerShell'
      assert "nextdns-doh-setup validates the profile id" \
          contains "$ndd_src" 'ValidatePattern'
      # The single most important doctrine in the file: the IP is not the profile.
      assert "nextdns-doh-setup states the anycast IP does NOT select the profile" \
          contains "$ndd_src" 'DOES NOT SELECT YOUR PROFILE'
      assert "nextdns-doh-setup disables UDP fallback (silent-unfiltered guard)" \
          contains "$ndd_src" 'AllowFallbackToUdp $false'
      assert "nextdns-doh-setup records that a wrong profile id fails silently" \
          contains "$ndd_src" 'FAILS SILENTLY'
      assert "nextdns-doh-setup verifies via test.nextdns.io rather than trusting config" \
          contains "$ndd_src" 'test.nextdns.io'
      assert "nextdns-doh-setup writes an on-disk breadcrumb for future readers" \
          contains "$ndd_src" 'README-dns-setup.md'
      assert "nextdns-doh-setup handles the tray-client conflict" \
          contains "$ndd_src" 'KeepClient'
      assert "nextdns-doh-setup flags the now-redundant logon flush task" \
          contains "$ndd_src" 'nextdns-boot-fix.ps1 -Remove -Apply'
      assert "nextdns-doh-setup is honest that the -Apply path is unverified" \
          contains "$ndd_src" 'NOT executed by its author'
      # REGRESSION GUARD (bit for real on first live -Apply, 2026-08-24): the original
      # verification tested for an EMPTY clientName and so reported [FAIL] on a perfectly
      # good machine-scope setup. NextDNS always returns a clientName; for the Windows
      # resolver it is 'unknown-doh'. The predicate must key on "is it the tray app?",
      # never on emptiness.
      assert "nextdns-doh-setup knows 'unknown-doh' is the healthy machine-scope value" \
          contains "$ndd_src" 'unknown-doh'
      assert "nextdns-doh-setup verification does NOT test for an empty clientName" \
          bash -c '! grep -q -- "-not \$e.clientName" <<<"$0"' "$ndd_src"
      assert "nextdns-doh-setup keys verification on clientName -ne nextdns-windows" \
          contains "$ndd_src" '$e.clientName -ne ' "'nextdns-windows'"
      assert "nextdns-doh-setup breadcrumb teaches the profile-pinning check, not just DOH" \
          contains "$ndd_src" 'prove the PROFILE, not just the encryption'
      # --- -SetPerInterface: make the Settings GUI agree with reality ---
      # Registering a template with AutoUpgrade makes DoH WORK but leaves the GUI reading
      # "Off", because the GUI reads a per-interface key instead of the known-servers table.
      # That mismatch is the hazard: pressing Save in that dialog can silently disable
      # encryption. DohFlags=1 (QWORD) = "automatic template" - verified against a working
      # public implementation, not guessed.
      assert "nextdns-doh-setup offers -SetPerInterface" \
          contains "$ndd_src" '$SetPerInterface'
      assert "nextdns-doh-setup writes DohFlags as a QWORD" \
          contains "$ndd_src" "-PropertyType QWord"
      assert "nextdns-doh-setup uses DohFlags value 1 (automatic template)" \
          contains "$ndd_src" "-Name 'DohFlags' -Value 1"
      assert "nextdns-doh-setup targets the interface-specific DoH path" \
          contains "$ndd_src" 'DohInterfaceSettings'
      assert "nextdns-doh-setup handles both Doh and Doh6 families" \
          contains "$ndd_src" "'Doh','Doh6'"
      assert "nextdns-doh-setup does NOT duplicate DohTemplate per-interface" \
          bash -c '! grep -q "Name .DohTemplate." <<<"$0"' "$ndd_src"
      assert "nextdns-doh-setup warns when applying without -SetPerInterface" \
          contains "$ndd_src" 'Settings GUI reports DoH "Off"'
      assert "nextdns-doh-setup rollback removes the per-interface key" \
          contains "$ndd_src" 'Removed per-interface DoH key'
      assert "nextdns-doh-setup verification reports what the GUI will show" \
          contains "$ndd_src" 'Settings GUI will report DoH ON'
      # --- roaming gap + maintenance ---
      # The config is PER-ADAPTER, so any NIC left unconfigured silently uses its
      # network's DNS - on a filtered LAN that is the router profile the whole setup
      # exists to escape. -AllAdapters closes it; -Doctor detects it.
      assert "nextdns-doh-setup offers -AllAdapters" \
          contains "$ndd_src" '$AllAdapters'
      assert "nextdns-doh-setup offers -Doctor" \
          contains "$ndd_src" '$Doctor'
      assert "nextdns-doh-setup selects physical NICs via HardwareInterface" \
          contains "$ndd_src" 'HardwareInterface'
      assert "nextdns-doh-setup excludes virtual/tunnel adapters from -AllAdapters" \
          contains "$ndd_src" 'Bluetooth'
      assert "nextdns-doh-setup documents the roaming gap" \
          contains "$ndd_src" 'THE ROAMING GAP'
      assert "nextdns-doh-setup warns about uncovered adapters in the plan" \
          contains "$ndd_src" 'Roaming gap:'
      assert "nextdns-doh-setup doctor audits per-adapter coverage" \
          contains "$ndd_src" 'ADAPTER COVERAGE'
      assert "nextdns-doh-setup doctor detects a tray-client conflict" \
          contains "$ndd_src" 'CLIENT CONFLICT'
      assert "nextdns-doh-setup doctor flags AllowFallbackToUdp being re-enabled" \
          contains "$ndd_src" 'AllowFallbackToUdp is TRUE'
      # Pinning must be self-calibrating: comparing system vs template vs unfiltered
      # needs no hardcoded answers, so it cannot rot when a profile's lists change.
      assert "nextdns-doh-setup doctor proves pinning by comparison, not hardcoded values" \
          contains "$ndd_src" 'unfiltered='
      assert "nextdns-doh-setup rollback covers every physical adapter" \
          contains "$ndd_src" 'foreach ($a in Get-PhysicalAdapters)'
      
      # The SKILL text and culprit catalog must carry the pattern, not just the scripts.
      skill_src="$(cat "$root/SKILL.md")"
      culprits_src="$(cat "$root/references/common-culprits.md")"
      cases_src="$(cat "$root/references/case-studies.md")"
      assert "SKILL.md documents the interception-layer adapter-DNS trap" \
          contains "$skill_src" 'Interception-Layer DNS Clients'
      assert "SKILL.md lists nextdns-audit in the scripts index" \
          contains "$skill_src" 'nextdns-audit.ps1'
      assert "SKILL.md lists nextdns-boot-fix in the scripts index" \
          contains "$skill_src" 'nextdns-boot-fix.ps1'
      assert "SKILL.md description carries the flush-every-reboot trigger" \
          contains "$skill_src" 'flushdns needed after every reboot'
      assert "common-culprits has the W4b boot-order entry" \
          contains "$culprits_src" 'W4b. NextDNS Boot-Order Profile Inheritance'
      assert "common-culprits W4 no longer misattributes a 127.0.0.1:53 proxy to NextDNS" \
          contains "$culprits_src" 'NOT NextDNS (v3.x)'
      assert "common-culprits records the rejected-fix table" \
          contains "$culprits_src" 'Fixes that do NOT work'
      assert "case-studies has the NextDNS case with its false leads" \
          contains "$cases_src" 'The Profile That Only Existed After Logon'
      
      # Determine the local OS probe for testing
      case "$(uname -s)" in
          Darwin) probe="$root/scripts/macos/probe.sh"; audit="$root/scripts/macos/dns-audit.sh" ;;
          Linux)  probe="$root/scripts/linux/probe.sh"; audit="$root/scripts/linux/dns-audit.sh" ;;
          *) echo "Skipping: unsupported OS for local probe tests." ; exit 0 ;;
      esac
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- Probe structural tests ---"
      # ---------------------------------------------------------------------------
      
      out=$(bash "$probe" 2>&1)
      
      assert "probe runs without bash error" \
          not_contains "$out" "syntax error"
      assert "probe runs without unbound variable error" \
          not_contains "$out" "unbound variable"
      assert "probe emits summary block" \
          contains "$out" "=== SUMMARY ==="
      assert "probe emits PASS/FAIL counts" \
          contains "$out" "PASS:"
      check_all_sections() {
          local out="$1"
          for s in "1. LINK LAYER" "2. IP / ICMP" "3. TCP/UDP SOCKET" "4. DNS INFRASTRUCTURE" "6. APPLICATION" "7. KNOWN VPN"; do
              contains "$out" "=== $s" || return 1
          done
          # Section 5 has OS-specific naming; match on the common anchor.
          contains "$out" "(the hook layer)" || return 1
          return 0
      }
      assert "probe contains all 7 sections" check_all_sections "$out"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- --redact tests ---"
      # ---------------------------------------------------------------------------
      
      redacted=$(bash "$probe" --redact 2>&1)
      
      # Common private patterns that should NEVER appear in redacted output.
      # (We use specific octets that are unlikely to appear in unrelated contexts.)
      assert "--redact masks 192.168.x.x" \
          bash -c '! grep -E "\b192\.168\.[0-9]+\.[0-9]+\b" <<< "$0" | grep -v "192.168.X.X" >/dev/null' "$redacted"
      assert "--redact masks .ts.net tailnet names" \
          bash -c '! grep -E "\b[a-z0-9-]+\.ts\.net\b" <<< "$0" | grep -v "REDACTED.ts.net" >/dev/null' "$redacted"
      assert "--redact preserves 100.100.100.100 anchor" \
          bash -c '[[ "$0" != *"100.X.X.X"* ]] || grep -q "100.100.100.100" <<< "$0"' "$redacted"
      assert "--redact preserves 1.1.1.1 public anchor" \
          contains "$redacted" "1.1.1.1"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- --json tests ---"
      # ---------------------------------------------------------------------------
      
      json_out=$(bash "$probe" --json 2>&1)
      
      assert "--json emits at least one section record" \
          contains "$json_out" '"type":"section"'
      assert "--json emits at least one check record" \
          contains "$json_out" '"type":"check"'
      assert "--json emits a summary record" \
          contains "$json_out" '"type":"summary"'
      assert "--json summary contains pass count" \
          bash -c 'grep -q "\"type\":\"summary\".*\"pass\":[0-9]" <<< "$0"' "$json_out"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- Dispatcher test ---"
      # ---------------------------------------------------------------------------
      
      disp_out=$("$root/scripts/probe" 2>&1 | tail -5)
      assert "dispatcher routes to per-OS probe (summary present)" \
          contains "$disp_out" "PASS:"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- dns-audit smoke test ---"
      # ---------------------------------------------------------------------------
      
      audit_out=$(bash "$audit" 2>&1)
      assert "dns-audit runs without error" \
          not_contains "$audit_out" "syntax error"
      assert "dns-audit emits attribution hints section" \
          contains "$audit_out" "ATTRIBUTION HINTS"
      
      # ---------------------------------------------------------------------------
      echo
      echo "--- Edge cases ---"
      # ---------------------------------------------------------------------------
      
      # --json should emit ONLY JSON (no chatter leaking through)
      json_pure=$(bash "$probe" --json 2>&1)
      non_json=$(echo "$json_pure" | grep -vc '^{')
      assert "--json produces pure NDJSON (no non-JSON chatter)" \
          bash -c '[[ "$0" -eq 0 ]]' "$non_json"
      
      # --json + --redact: redacted private addrs AND only JSON
      combo=$(bash "$probe" --json --redact 2>&1)
      combo_non_json=$(echo "$combo" | grep -vc '^{')
      combo_leaks=$(echo "$combo" | grep -E "\b192\.168\.[0-9]+\.[0-9]+\b" | grep -v "192.168.X.X")
      assert "--json + --redact produces pure NDJSON" \
          bash -c '[[ "$0" -eq 0 ]]' "$combo_non_json"
      assert "--json + --redact has no private-IP leaks" \
          bash -c '[[ -z "$0" ]]' "$combo_leaks"
      
      # Unknown flag should not crash
      assert "unknown --frobnicate flag does not crash" \
          bash -c 'bash "$0" --frobnicate 2>&1 | grep -q "PASS\\|FAIL"' "$probe"
      
      # Help flag prints usage and exits cleanly
      help_out=$(bash "$probe" --help 2>&1)
      assert "--help mentions --redact" \
          contains "$help_out" "--redact"
      assert "--help mentions --json" \
          contains "$help_out" "--json"
      assert "--help mentions --quick" \
          contains "$help_out" "--quick"
      
      # Dispatcher works from a different cwd
      disp_remote=$(cd /tmp && "$root/scripts/probe" 2>&1 | tail -5)
      assert "dispatcher works from /tmp (cwd-independent)" \
          contains "$disp_remote" "PASS:"
      
      # ---------------------------------------------------------------------------
      echo
      echo "=== TOTAL: $PASS pass, $FAIL fail ==="
      if [[ "$FAIL" -gt 0 ]]; then
          echo "Failed tests:"
          for t in "${FAILED_TESTS[@]}"; do echo "  - $t"; done
          exit 1
      fi
      exit 0
      
  • SKILL.md 15.2 KB
    ---
    name: net-ops
    description: "Cross-platform network troubleshooting (Windows, macOS, Linux) via local or remote shell. Use for: DNS broken, can't resolve hostnames, nslookup/dig works but apps fail, NRPT, WFP, scutil, /etc/resolver, systemd-resolved, resolv.conf, NetworkManager, VPN DNS leak residue (ProtonVPN/Mullvad/WireGuard/AnyConnect), AV/firewall blocking DNS or DoH, Tailscale DNS, remote diagnostics over SSH, mapped drive Disconnected, SMB share unreachable, NAS by hostname fails but IP works, single-label hostname, LLMNR/NetBIOS, System error 5 on net use, NextDNS, DoH profile pinning, flushdns needed after every reboot, wrong DNS profile after reboot, inheriting router DNS profile, boot-order DNS race."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: debug-ops
    ---
    
    # Network Operations
    
    Diagnose network problems on Windows, macOS, or Linux with a layered ladder that isolates faults to the smallest possible scope, then pattern-match against OS-specific culprits. Designed for the common case: someone reports "internet broken" on a box you can shell into (locally or via SSH).
    
    ## The Universal Insight
    
    **Bypass-tool succeeds while OS-resolver fails is a smoking gun on every platform.** It means DNS infrastructure is healthy but the operating system's name-resolution path is hooked or misconfigured. The bypass tool differs per OS but the discriminator is identical:
    
    | OS | Bypass tool | OS resolver tool | If bypass works but resolver fails |
    |---|---|---|---|
    | Windows | `nslookup` | `Resolve-DnsName`, browsers | NRPT, WFP, HOSTS, LSP, local 127.0.0.1:53 proxy |
    | macOS | `dig @1.1.1.1` | `dscacheutil -q host`, browsers | `/etc/resolver/*`, scutil DNS, profiles, mDNSResponder, kext |
    | Linux | `dig @1.1.1.1` | `getent hosts`, `resolvectl query` | systemd-resolved, `/etc/resolv.conf`, NetworkManager, dnsmasq, NSS |
    
    The bypass tool implements its own resolver and talks straight to UDP/53. The OS resolver tool goes through the full system name-service path including all hooks. Comparing the two narrows the suspect list dramatically.
    
    ## The Diagnostic Ladder
    
    Walk down the layers in order. **Do not skip rungs.** Each rung has a binary outcome that eliminates everything above it. Per-OS tools are in `references/diagnostic-ladder.md`; the structure is universal.
    
    ```
    1. Link layer        — interface up, valid IP, gateway present
    2. IP reachability   — ping public IPs over ICMP
    3. Socket reach.     — TCP/443 + UDP/53 to known destinations (raw socket DNS)
    3.5 LAN services     — parallel track: mapped drives, SMB, mDNS/.local,
                           single-label hostnames (see below — internet OK ≠ LAN OK)
    4. DNS infrastructure — bypass tool: nslookup / dig @<server>
    5. OS resolver path  — the hook layer (most interesting on modern systems)
    6. Application       — real HTTP request to a real hostname
    ```
    
    The most common mistake: jumping to rung 6 ("HTTPS doesn't work, must be a cert / proxy") when rung 5 is the actual problem (an orphan VPN DNS rule on Windows, a stale `/etc/resolver/` file on macOS, a misconfigured systemd-resolved on Linux). Discipline prevents this.
    
    ## The LAN Services Track (Rung 3.5)
    
    Rungs 1–6 are framed around reaching the *public internet*. A mapped drive showing `Disconnected` while browsing works fine is a different fault domain: **local-network service reachability**. Symptoms: SMB shares, printers, `\\NAS\share`, `.local` names, single-label hostnames.
    
    The load-bearing concept is the **single-label hostname resolution path**:
    
    ```
    HOSTS file → NRPT match → DNS (suffix search) → LLMNR → NetBIOS-NS
    ```
    
    An NRPT `.` catch-all (live VPN or orphan) short-circuits everything below it: the single-label name goes to the VPN resolver (NXDOMAIN) **and** the LLMNR/NetBIOS broadcast fallback that LAN names normally rely on is suppressed. VPN DNS-leak-protection often also blocks UDP/53 to the LAN router, so there's no fallback resolver either. Full chain, per-OS tools, and the credential-target gotcha: `references/diagnostic-ladder.md` (rung 3.5).
    
    **One-shot audit:** `scripts/windows/smb-audit.ps1` walks every mapped drive through mapping state → resolution mechanism → ICMP/TCP-445 → credential targeting → NRPT/leak-protection detection, and emits a verdict naming the fix.
    
    **VPN + LAN coexistence — decision rule.** Is the VPN *live and wanted*?
    
    - **Yes** → do NOT touch the NRPT rule. Pin the name in the HOSTS file (consulted before NRPT, so it wins regardless of VPN state; needs admin), or remap by IP *and* add a credential keyed to that IP (`cmdkey /add:<ip>`).
    - **No** (VPN gone, rule orphaned) → `scripts/windows/nrpt-clean.ps1 -Apply`.
    
    **`nrpt-clean.ps1` must never be pointed at a live VPN's catch-all** — it exists for orphans only. Deleting a wanted VPN's rule breaks its DNS routing and the client will just re-create it.
    
    ## Interception-Layer DNS Clients (the adapter-DNS trap)
    
    Modern DNS-filtering clients (NextDNS v3.x, and increasingly others) do **not** bind port 53
    and do **not** run a loopback proxy. They install a **WFP callout driver** and rewrite queries
    to DoH in the kernel. This inverts three habits that are otherwise reliable:
    
    | Habit | Why it misleads here |
    |---|---|
    | Read adapter DNS to learn the resolver | **Cosmetic.** It can show the DHCP router address while every query leaves over DoH. |
    | `Get-NetUDPEndpoint -LocalPort 53` to find the proxy | Returns **nothing** for the client. Whoever holds `0.0.0.0:53` (often `SharedAccess`/ICS) is unrelated. |
    | Query `127.0.0.1` to test the local resolver | Times out **by design**. There is no local resolver. |
    
    **Ground truth is a query, not a config dump.** Ask the provider what it sees:
    
    ```powershell
    $r = -join ((1..20) | % { '0123456789abcdefghijklmnopqrstuvwxyz'[(Get-Random -Max 36)] })
    (Invoke-WebRequest "https://$r.test.nextdns.io/" -UseBasicParsing).Content
    ```
    
    `clientName: nextdns-windows` means the client owns the query path *right now*.
    
    **The boot-order failure this enables.** When the client's profile is stored **per-user** but
    its service starts at **boot**, the service has no profile until the tray hands one over at
    **logon**. In that gap DNS resolves via DHCP — often a router running a stricter profile — and
    Windows **caches** those answers. Interception then self-corrects; the cache does not. Symptom:
    `ipconfig /flushdns` fixes things after every reboot, forever.
    
    That "flush alone fixes it" observation is diagnostic gold — it proves the resolver config is
    already correct and only the cache is stale, which rules out every reconfiguration-style fix
    (delayed start, service dependencies, static adapter DNS). Audit with
    `scripts/windows/nextdns-audit.ps1`; remedy with `scripts/windows/nextdns-boot-fix.ps1 -Apply`.
    Full pattern, rejected alternatives, and the machine-scope DoH option: `references/common-culprits.md` (W4, W4b).
    
    ## Workflow
    
    ### 1. Identify the target OS
    
    If local: `uname -s` (Unix) or check shell environment. If remote over SSH, the bootstrap script auto-detects:
    
    ```bash
    scripts/ssh-bootstrap.sh <user>@<host>
    ```
    
    ### 2. Run the OS-appropriate probe
    
    | OS | Script |
    |---|---|
    | Windows | `scripts/windows/probe.ps1` (via `-EncodedCommand` over SSH) |
    | macOS | `scripts/macos/probe.sh` |
    | Linux | `scripts/linux/probe.sh` |
    
    Each prints structured `[PASS]/[FAIL]` per rung. Scan for the first FAIL — that's where to drill in.
    
    ### 3. Drill into the failing layer
    
    The interesting failures are almost always rung 5. Per-OS deep-dive scripts:
    
    | OS | Script | What it does |
    |---|---|---|
    | Windows | `scripts/windows/nrpt-audit.ps1` | Dump NRPT rules with attribution + registry forensics |
    | Windows (LAN/SMB) | `scripts/windows/smb-audit.ps1` | Mapped-drive audit: resolution mechanism, reachability, credential targets, NRPT/leak-protection, verdict |
    | Windows (NextDNS) | `scripts/windows/nextdns-audit.ps1` | NextDNS client: config scope, boot→logon exposure window, effective profile, verdict |
    | macOS | `scripts/macos/dns-audit.sh` | Dump scutil --dns, /etc/resolver/*, mDNSResponder state, profiles |
    | Linux | `scripts/linux/dns-audit.sh` | Dump systemd-resolved status, resolv.conf chain, NM config, NSS order |
    
    ### 4. Apply the minimum reversible fix
    
    Repair scripts default to **dry-run** and protect known-good config (Tailscale MagicDNS, MDM-managed entries). Apply only when the dry-run output matches expectation.
    
    | OS | Repair script |
    |---|---|
    | Windows | `scripts/windows/nrpt-clean.ps1` (removes orphan NRPT catch-alls, protects Tailscale) |
    | Windows | `scripts/windows/nextdns-boot-fix.ps1` (logon task: flush once after NextDNS interception is confirmed) |
    | Windows | `scripts/windows/nextdns-doh-setup.ps1` (machine-scope: point the OS resolver at a profile-pinned DoH template; needs admin) |
    | macOS | `scripts/macos/resolver-clean.sh` (removes orphan `/etc/resolver/*` from disconnected VPNs) |
    | Linux | `scripts/linux/resolved-reset.sh` (resets systemd-resolved per-link config) |
    
    ## Quick Reference: Smoking Guns
    
    | Platform | Symptom | Most likely cause | Quick test |
    |---|---|---|---|
    | Windows | `nslookup` works, browsers fail | Orphan NRPT catch-all (VPN residue) | `Get-DnsClientNrptRule \| Where Namespace -eq '.'` |
    | Windows | Public DoH resolver IPs blocked on 443, other 443 works | AV "Encrypted DNS Detection" | `Get-CimInstance -Ns root/SecurityCenter2 -Class AntiVirusProduct` |
    | Windows | `ipconfig /flushdns` fixes DNS after **every** reboot, then it breaks again | DNS-filtering client whose profile is per-user while its service starts at boot — the boot→logon gap resolves via the router and Windows caches those answers (NextDNS: W4b) | `scripts/windows/nextdns-audit.ps1` |
    | Windows | A DoH client is "started" yet nothing owns `:53` and `127.0.0.1` times out | Working as designed — modern clients (NextDNS v3.x) intercept via a WFP kernel driver, never bind 53; adapter DNS is cosmetic | `https://<random>.test.nextdns.io/` → read `clientName` |
    | macOS | `dig` works, browsers fail | Stale `/etc/resolver/*` from disconnected VPN | `ls /etc/resolver/ && scutil --dns \| head -40` |
    | macOS | All DNS fails post-VPN install | Configuration profile with DNS override | `profiles list -type configuration` |
    | Linux | `dig` works, `getent hosts` fails | systemd-resolved misconfigured | `resolvectl status` |
    | Linux | DNS works on some apps, not others | NSS order in `/etc/nsswitch.conf` excludes `resolve` | `grep ^hosts /etc/nsswitch.conf` |
    | All | DNS suddenly broken after sleep/wake | VPN client failed disconnect cleanup | OS-specific (see above) |
    | Windows | Mapped drive `Disconnected`, host pings by IP but not by name, VPN active | NRPT `.` catch-all swallowing single-label names (+ suppressing LLMNR/NetBIOS fallback) | `Get-DnsClientNrptPolicy -Effective \| ? Namespace -eq '.'` |
    | Windows | `net view \\<ip>` returns `System error 5` while the same share worked by hostname | Credential Manager target keyed to hostname, not IP | `cmdkey /list` |
    | All | LAN hosts reachable by IP but router's DNS times out on UDP/53 while TCP to it succeeds | VPN DNS-leak-protection egress filter | `scripts/windows/smb-audit.ps1` (LAN DNS EGRESS section) |
    
    ## SSH Transport Patterns
    
    ### Windows targets
    
    PowerShell-over-SSH has notorious escaping issues. Always pass scripts via `-EncodedCommand` with UTF-16LE base64:
    
    ```bash
    B64=$(printf '%s' "$PS_SCRIPT" | iconv -t UTF-16LE | base64)
    ssh <target> "powershell -NoProfile -EncodedCommand $B64"
    ```
    
    ### Unix targets (macOS, Linux)
    
    Heredoc works cleanly; no special encoding needed:
    
    ```bash
    ssh <target> 'bash -s' < scripts/linux/probe.sh
    # or, with arguments:
    ssh <target> "bash -s -- arg1 arg2" < scripts/linux/probe.sh
    ```
    
    For consistency, `scripts/ssh-bootstrap.sh` handles both transports based on detected OS.
    
    ## Pattern Recognition
    
    After a few sessions, certain symptom triplets become instantly diagnosable. See `references/case-studies.md` for worked examples. Hall-of-fame entries:
    
    **Windows:** `nslookup` works, `Resolve-DnsName` times out identically across all servers, `Invoke-WebRequest` says "remote name could not be resolved" → orphan NRPT catch-all from a disconnected VPN. Common gateway IP patterns are listed in `references/common-culprits.md`.
    
    **macOS:** `dig <host>` works, browsers say "cannot find server," `scutil --dns` shows extra "resolver #N" entries pointing at private-range gateways with `domain :` listed → leftover `/etc/resolver/<domain>` files from a disconnected VPN.
    
    **Linux:** `dig @<public-resolver> <host>` works, `getent hosts <host>` fails → `/etc/nsswitch.conf` may have an NSS chain that skips `resolve`, OR `/etc/resolv.conf` is no longer symlinked to the systemd-resolved stub.
    
    ## Safety Notes
    
    - **Read before write.** Always dump current state before modifying a resolver config. The forensics may be load-bearing for explaining what happened.
    - **Don't disable security tools without consent.** AV / firewall hooks are intrusive but legitimate. Pause is preferred over uninstall.
    - **Tailscale's name-resolution config looks like junk but is essential.** Always filter on protected nameserver patterns (`100.100.100.100` on all OSes) before bulk-deleting.
    - **Resolver config persists across reboots.** Removing a rule is forever (until the VPN re-creates it). Confirm the source/comment before deletion.
    - **macOS profile DNS overrides may be MDM-managed.** Removing them may violate enterprise policy and may be re-applied automatically. Coordinate with IT.
    
    ## References
    
    - `references/diagnostic-ladder.md` — full ladder methodology with per-OS commands per rung
    - `references/common-culprits.md` — detection + fix catalog for Windows / macOS / Linux
    - `references/case-studies.md` — worked examples and template for adding new ones
    
    ## Scripts
    
    - `scripts/ssh-bootstrap.sh` — establish SSH session, auto-detect target OS, emit usable invocation
    - `scripts/windows/probe.ps1` — full layered diagnostic for Windows
    - `scripts/windows/nrpt-audit.ps1` — NRPT forensics with attribution
    - `scripts/windows/nrpt-clean.ps1` — safe NRPT cleanup (orphans ONLY — never point it at a live VPN's catch-all; protects Tailscale)
    - `scripts/windows/smb-audit.ps1` — mapped-drive / SMB / LAN-name audit with per-drive verdicts (`-DriveLetter Z -FallbackIp 'NAS=192.168.1.50'`, `-Json`)
    - `scripts/windows/nextdns-audit.ps1` — NextDNS client audit: config scope, boot→logon exposure window, effective profile via `test.nextdns.io` (`-SkipNetwork`, `-Json`)
    - `scripts/windows/nextdns-boot-fix.ps1` — installs a per-user logon task that flushes the DNS cache once NextDNS interception is confirmed (dry-run by default; `-Apply`, `-Remove`)
    - `scripts/windows/nextdns-doh-setup.ps1` — machine-scope alternative: points the Windows DNS Client at a profile-pinned NextDNS DoH template so DNS is correct from boot, disables the conflicting tray client, writes an on-disk breadcrumb, verifies, and rolls back (`-Apply`, `-Rollback`, `-VerifyOnly`; needs admin)
    - `scripts/macos/probe.sh` — full layered diagnostic for macOS
    - `scripts/macos/dns-audit.sh` — scutil + /etc/resolver + profile + mDNSResponder dump
    - `scripts/macos/resolver-clean.sh` — remove orphan /etc/resolver/* files
    - `scripts/linux/probe.sh` — full layered diagnostic for Linux
    - `scripts/linux/dns-audit.sh` — systemd-resolved + NM + NSS + resolv.conf dump
    - `scripts/linux/resolved-reset.sh` — reset systemd-resolved per-link state
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related