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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/net-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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.100on 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 rungreferences/common-culprits.md— detection + fix catalog for Windows / macOS / Linuxreferences/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 invocationscripts/windows/probe.ps1— full layered diagnostic for Windowsscripts/windows/nrpt-audit.ps1— NRPT forensics with attributionscripts/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 viatest.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 macOSscripts/macos/dns-audit.sh— scutil + /etc/resolver + profile + mDNSResponder dumpscripts/macos/resolver-clean.sh— remove orphan /etc/resolver/* filesscripts/linux/probe.sh— full layered diagnostic for Linuxscripts/linux/dns-audit.sh— systemd-resolved + NM + NSS + resolv.conf dumpscripts/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.
Reviews (0)
No reviews yet.
No comments yet.