Skip to content

abblake/portaldoc

Repository files navigation

portaldoc

Offline captive-portal diagnostic for macOS — names the cause, walks the fix.

You're at an airport, a hotel, a coffee shop. You join the Wi-Fi, the sign-in page never loads, and you have no internet — which is the exact moment you can't Google why. portaldoc runs fully offline on the stuck Mac: it probes the real network state, names the most-likely cause, extracts the portal URL, and walks you through the exact fix. An optional local LLM narrates the diagnosis, grounded strictly on the probe facts — never the public internet.

What you see

Run it behind a broken portal and the deterministic core renders instantly — before any model loads:

══════════════════════════════════════════════════════════════
  ✗ PORTAL NOT CLEARED — HTTPS/HSTS no-pop
══════════════════════════════════════════════════════════════
  SSID AirportFreeWiFi · 5GHz · -58dBm   IP 10.4.21.88/22   GW 10.4.20.1 ✓
  DHCP lease ✓   DNS 10.4.20.1   captive portal-present   https TLS-error

  Cause: the OS tried HTTPS first; the portal can't intercept it (HSTS),
  so the sign-in page never popped. The portal URL was found in the
  redirect — open it over plain HTTP to trigger the login page.

  [Enter] OPEN PORTAL  →  http://login.airportfreewifi.net/auth
  [2] copy: open http://login.airportfreewifi.net/auth
  [r] re-probe   [q] quit

  model thinking…
──────────────────────────────────────────────────────────────

The verdict, the fact-strip, and the OPEN-PORTAL button render with no model and no internet. If a local model is present, narration streams in underneath to explain the "why" — it never gates the action.

(Illustrative output; exact rendering varies with your network state.)

Why it's different

  • Offline-first. The whole tool runs on the machine that's stuck. No internet is required, because the absence of internet is the problem it exists to solve.
  • The deterministic core stands alone. Six probes (link, DHCP, gateway, DNS, captive-detection, HTTPS) parse real macOS network output into a typed factsheet, and a pure router maps that to a named cause and verified fix steps. This works with the model off and Docker down — it is the product, not a fallback.
  • The LLM is a grounded reasoner, not a free one. When present, the model only narrates the factsheet plus the matched playbook. It reasons over injected facts — never from its own weights — and if the facts don't support an answer, it says so. The diagnosis is already decided deterministically; the model just explains it in plain language.
  • It never goes dark. Model, retrieval, and Docker are enrichment layered on top. Lose any of them and portaldoc degrades cleanly to the verified deterministic steps. There is always a diagnosis.

Install & run

Requirements: macOS and Bun. Optional: Ollama with a local model for narration; Docker/Chroma for semantic retrieval (both degrade gracefully when absent).

git clone https://github.com/abblake/portaldoc.git
cd portaldoc
bun install

Run the interactive cockpit (launches when stdout is a TTY):

bun run src/cli.ts

Flags:

bun run src/cli.ts --once        # single probe pass, no interactive loop
bun run src/cli.ts --json        # machine-readable factsheet to stdout
bun run src/cli.ts --no-llm      # deterministic core only, skip narration
bun run src/cli.ts --audit-net   # log every outbound packet (proves the offline claim)

Optional one-time setup for narration — pull the model portaldoc narrates with:

ollama pull qwen3:14b            # narrator (configurable via env/config)
ollama pull nomic-embed-text     # local embeddings, only used if Chroma is up

The narrator model is configurable; a smaller model can drop in on modest hardware. With no model present, the tool simply shows the verified fix steps.

How it works

  1. Probe suite. Six probes run real, non-sudo-preferring macOS commands (system_profiler SPAirPortDataType, scutil --dns, ipconfig getpacket, route, plus the captive and HTTPS reachability checks). Each probe is total — it never throws; it returns a status envelope so "couldn't measure" never masquerades as "measured false."
  2. Factsheet. Probe outputs assemble into one typed FactSheet — the single source of truth for everything above it.
  3. Cause matching. A pure, deterministic router computes a signature from the factsheet and matches it against a corpus of symptom → cause → fix playbooks. It detects the top public-Wi-Fi failure modes: HTTPS/HSTS no-pop, custom DNS / DoH, MAC randomization, no/stale DHCP lease, gateway unreachable, and already-authed. It extracts the portal URL from the redirect when one is present.
  4. Grounded narration (optional). The factsheet plus the matched playbook(s) are handed to a local Ollama model that explains the fix and quotes your actual values (IP, SSID, gateway) so you can trust the read. It streams on top of the already-rendered deterministic output.
  5. Re-probe loop. Apply a fix, press r, and re-probe. The cockpit shows a field old→new ✓ delta so you can watch the network recover — the delta is the information, never a silent full re-render. The banner flips to ✓ ONLINE when the portal clears.

Offline & privacy guarantee

portaldoc makes zero public-internet calls for knowledge or help lookup. Playbooks are baked in; the model reasons only over local facts.

The only packets it emits are the measurement itself: the captive-detection and HTTPS-reachability probes hit fixed, well-known endpoints (captive.apple.com and an Apple HTTPS host) — the same endpoints macOS already uses to detect captive portals. Those are the diagnostic, not a knowledge lookup. When narration is enabled, the model and embeddings talk to Ollama/Chroma over loopback only (127.0.0.0/8, ::1, localhost); an off-box URL is refused and the deterministic output stands.

Prove it yourself:

bun run src/cli.ts --audit-net

--audit-net cross-checks every runtime emitter against a static manifest and asserts that only the two captive/HTTPS endpoints (plus loopback for Ollama/Chroma) ever see a byte. Any off-box config is reported as a violation.

Security model

Public Wi-Fi means a hostile access point can control the SSID and portal URL — portaldoc treats those as untrusted input throughout.

  • Fixes are surfaced, never auto-run. Any command that needs sudo is shown as copy-paste only and tagged (sudo). The single confirmed action is opening the extracted portal URL, and only after a scheme allowlist (http/https only — file://, javascript:, data:, and similar are blocked).
  • The model is held inside an allow-set. The narrator may only surface commands present in the matched playbook's fix steps. Anything the model emits that isn't in the verified allow-set is redacted inline ([redacted: ungrounded command]) before it can render as runnable — the TUI is never more permissive than the headless CLI.
  • Attacker-controlled fields are defanged. SSID and portal URL are wrapped, control-char-stripped, and length-capped before they reach the prompt, and the model is instructed to distrust them. This grounding defense was hardened across two rounds of adversarial cross-vendor review against the hostile-AP / prompt-injection threat model.

Status

Early and experimental. macOS-only. The deterministic core, grounded narration, and interactive TUI are feature-complete and test-covered, but this is a young tool — expect rough edges and please file what you find.

Contributing

Contributions welcome. See CONTRIBUTING.md.

License

MIT — see LICENSE.

About

Offline captive-portal diagnostic for macOS — names the cause, walks the fix.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors