diff --git a/README.md b/README.md index 09f1944..ab14561 100644 --- a/README.md +++ b/README.md @@ -3,31 +3,40 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/jaisonerick/macwifi.svg)](https://pkg.go.dev/github.com/jaisonerick/macwifi) [![CI](https://github.com/jaisonerick/macwifi/actions/workflows/ci.yml/badge.svg)](https://github.com/jaisonerick/macwifi/actions/workflows/ci.yml) -Real macOS WiFi data for Go. +Wi-Fi scanning and Keychain password access for Go programs on macOS 13+. -Recent macOS versions can redact WiFi details from CLI and background tools. -This lib helps you answer practical questions like "what network is this Mac -seeing?" and "what BSSID is it connected to?" +macOS 14.4 removed the `airport` CLI tool. `wdutil info` returns +`BSSID : ` even with `sudo`, and CoreWLAN's `scanForNetworks` +only returns real BSSIDs to apps signed with a stable Developer ID that +have Location Services permission — which scripts and most CLI tools +can't get ([Apple DTS forum thread][apple-dts]). -This package gives Go programs a straightforward API for scanning nearby WiFi -networks on macOS. It can also read saved WiFi passwords from the macOS Keychain -after the user approves the system prompt. +`macwifi` closes that gap by embedding a Developer-ID-signed and +notarized Swift helper bundle inside the Go package. Your Go binary +spawns the helper on first use to trigger the macOS Location Services +prompt; subsequent calls reuse it. -## Power TUI apps and internal WiFi tools +Looking for a ready-to-run command instead of a library? See +[`macwifi-cli`](https://github.com/jaisonerick/macwifi-cli) — a +drop-in `airport`-replacement built on this package. -`macwifi` is meant for Go developers building macOS-focused tools: +[apple-dts]: https://developer.apple.com/forums/thread/718331 + +## What you can build with it - Network diagnostics CLIs and TUIs. -- IT, inventory, and support utilities that need local WiFi context. -- Security and audit tools that need BSSID, channel, band, and security mode. -- Migration or recovery tools that need user-approved access to saved WiFi - passwords. -- Desktop agents that need a Go API while still respecting macOS privacy - prompts. - -It is not a cross-platform WiFi abstraction, packet-capture library, background -daemon, or privacy-control bypass. Location Services and Keychain access remain -under user control. +- IT, inventory, and support utilities that need local Wi-Fi context. +- Security and audit tools that need BSSID, channel, band, and security + mode. +- Migration or recovery tools that need user-approved access to saved + Wi-Fi passwords. +- Desktop agents that need a Go API while still respecting macOS + privacy prompts. + +Out of scope: cross-platform Wi-Fi abstraction, packet capture, +background daemons that bypass Location Services, or privacy-control +workarounds. Location Services and Keychain access stay under user +control. ## What You Get diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..b28575f --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,6 @@ +_site/ +.sass-cache/ +.jekyll-cache/ +.jekyll-metadata +vendor/ +Gemfile.lock diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..4103551 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" +gem "just-the-docs" +gem "jekyll-remote-theme" +gem "jekyll-seo-tag" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..a1a0d46 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,29 @@ +title: macwifi +description: Wi-Fi scanning and Keychain password access for Go programs on macOS 13+. +url: https://jaisonerick.github.io +baseurl: /macwifi + +remote_theme: just-the-docs/just-the-docs + +color_scheme: dark + +logo: "" +search_enabled: true + +aux_links: + Repo on GitHub: + - "https://github.com/jaisonerick/macwifi" + pkg.go.dev: + - "https://pkg.go.dev/github.com/jaisonerick/macwifi" + macwifi-cli: + - "https://github.com/jaisonerick/macwifi-cli" + +aux_links_new_tab: true + +footer_content: 'MIT licensed. Built and maintained by @jaisonerick.' + +heading_anchors: true + +plugins: + - jekyll-remote-theme + - jekyll-seo-tag diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..4f06494 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,240 @@ +--- +title: Getting started +layout: default +nav_order: 2 +description: "Install macwifi, handle the Location Services prompt, scan networks, and read saved Wi-Fi passwords from the Keychain on macOS 13+." +permalink: /getting-started +--- + +# Getting started with macwifi +{: .no_toc } + +This guide walks through installing `macwifi`, handling the macOS +Location Services prompt the first time you run a scan, and reading +saved Wi-Fi passwords from the Keychain. It also covers the things +that surprise people coming from `airport -s` or +`schollz/wifiscan`. + +## Table of contents +{: .no_toc .text-delta } + +1. TOC +{:toc} + +--- + +## Background + +If you maintain a Go program that touches Wi-Fi on macOS, the last +two years have not been kind: + +- **macOS 14.4 removed `/usr/libexec/airport`.** The CLI tool that + every script and Go library used to enumerate nearby networks + silently went away. Apple's note: *"The airport command line tool + is deprecated and will be removed in a future release."* +- **`wdutil info` redacts `BSSID`** even when run with `sudo`. +- **`networksetup` and `ioreg` don't expose nearby networks.** +- **CoreWLAN's `scanForNetworks`** only returns real BSSIDs to apps + that are signed with a stable Developer ID *and* have Location + Services permission. Apple's DTS team has confirmed [on the + developer forums][apple-dts] that scripts and `launchd` daemons + can't satisfy these requirements. + +Every Go library on pkg.go.dev that does Wi-Fi scanning on macOS +parses `airport` output, so all of them silently broke when Sonoma +14.4 shipped. `macwifi` exists because there was no remaining path +that worked from a Go program. + +[apple-dts]: https://developer.apple.com/forums/thread/718331 + +## Install + +```sh +go get github.com/jaisonerick/macwifi +``` + +Requirements: + +- macOS 13 or newer on Apple Silicon. +- Go 1.26 or newer. +- The first `Scan()` or `Password()` call will trigger the macOS + Location Services prompt. There is no API to suppress this — it's + the entire reason the library can return real BSSIDs. + +The Developer-ID-signed and notarized helper bundle is embedded in +the package via `go:embed` and extracted to a temporary directory on +first use. Your end users do not need to install anything separately. + +## First scan + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/jaisonerick/macwifi" +) + +func main() { + nets, err := macwifi.Scan(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "scan:", err) + os.Exit(1) + } + for _, n := range nets { + fmt.Printf("%-32s %s %3d dBm ch %d %s\n", + n.SSID, n.BSSID, n.RSSI, n.Channel, n.Security) + } +} +``` + +Expected output (after the user approves the Location Services +prompt the first time): + +``` +Office WiFi aa:bb:cc:dd:ee:ff -52 dBm ch 149 WPA2 +Guest 11:22:33:44:55:66 -71 dBm ch 36 WPA2 +Conference Room 77:88:99:aa:bb:cc -58 dBm ch 100 WPA3 +``` + +The `Network` struct returned from `Scan` carries everything CoreWLAN +exposes that a Go developer is likely to want: + +| Field | Description | +| -------------- | ------------------------------------------------------- | +| `SSID` | Wi-Fi network name. | +| `BSSID` | Access point MAC address. Empty until Location Services is approved. | +| `RSSI` | Signal strength in dBm. Closer to zero is stronger. | +| `Noise` | Noise floor in dBm, when macOS reports it. | +| `Channel` | Wi-Fi channel number. | +| `ChannelBand` | `2.4GHz`, `5GHz`, `6GHz`, or `unknown`. | +| `ChannelWidth` | Channel bandwidth in MHz. | +| `Security` | Open, WEP, WPA, WPA2, WPA3, enterprise, OWE, or unknown. | +| `PHYMode` | 802.11 mode when available. | +| `Current` | Whether the Mac is connected to this network now. | +| `Saved` | Whether the SSID is in the preferred-networks list. | +| `Password` | Always `""` from `Scan`; use `Password(ctx, ssid)`. | + +Saved networks that are not currently visible may be included with +the signal and channel fields set to zero. + +## Reusing the helper across calls + +`macwifi.Scan` and `macwifi.Password` are one-shot helpers that spawn +the embedded app, run a single request, and tear it down. If you +plan to do more than one operation in the same run — for example, +scan and then look up a password for the strongest network — keep a +client open: + +```go +ctx := context.Background() + +c, err := macwifi.New(ctx) +if err != nil { + panic(err) +} +defer c.Close() + +nets, err := c.Scan(ctx) +if err != nil { + panic(err) +} + +password, err := c.Password(ctx, "MyHomeWiFi", + macwifi.OnKeychainAccess(func(ssid string) { + fmt.Printf("→ approve the Keychain prompt for %q\n", ssid) + }), +) +if err != nil { + panic(err) +} + +fmt.Println(len(nets), "networks;", "password length:", len(password)) +``` + +Reusing the client matters because each fresh `New` call launches the +helper bundle, which goes through `open -W`, the Location Services +permission check, and a tiny TCP handshake to your Go process. +That's roughly half a second of latency per call. + +## Reading saved Wi-Fi passwords + +Saved Wi-Fi passwords live in the macOS System keychain and can be +retrieved via `CWKeychainFindWiFiPassword` from CoreWLAN. The first +time `macwifi.Password()` runs for an SSID, macOS will display its +own *Allow* / *Deny* dialog asking permission to read that Keychain +entry. + +A few things to know before you ship this: + +- The legacy **Always Allow** path is no longer available in the + Keychain dialog, so the prompt fires *every* time you call + `Password()` for a given SSID. Plan your UX accordingly. +- Use the `OnKeychainAccess` option to give the user a heads-up + before macOS shows its dialog: + +```go +password, err := macwifi.Password(ctx, ssid, + macwifi.OnKeychainAccess(func(ssid string) { + fmt.Printf("Approve the macOS Keychain prompt to read %q\n", ssid) + }), +) +``` + +- A return of `("", nil)` means there is no saved entry for that SSID. +- The helper bounds each `Password` call at 60 seconds by default + (generous, to cover the user reading the dialog). Use + `macwifi.WithTimeout(d)` to adjust. + +## Troubleshooting + +### "I get empty BSSIDs back" + +The user has not yet approved the Location Services prompt. Open +**System Settings → Privacy & Security → Location Services** and +confirm `WifiScanner` (or whatever bundle name your binary uses) is +enabled. The next scan will return real BSSIDs. + +### "I'm running this from a `launchd` daemon and it doesn't work" + +This is a hard limit imposed by macOS. From [Apple's DTS +team][apple-dts]: + +> Via a `launchd` daemon — That's unlikely to work. CoreWLAN checks +> for the Location privilege and that's hard for a daemon to get. + +The helper bundle uses `LSUIElement` so it doesn't appear in the +Dock, but it still runs as a foreground GUI process. That works from +a normal user-session program, an SSH session into the user account, +or a `launchd` *agent*. It does not work from a system-wide +`launchd` daemon. + +### "Some saved networks have empty signal/channel fields" + +That's expected. macOS includes saved-but-not-visible networks in +the result with `Saved: true`, `Current: false`, and the +signal/channel fields zeroed. Filter on `n.RSSI != 0 || n.Current` +if you only want networks that are reachable now. + +### "How do I rebuild the embedded helper for development?" + +If you're iterating on the Swift side, point `MACWIFI_APP` at a +locally-built bundle to skip the embedded one: + +```sh +make scanner +MACWIFI_APP="$PWD/WifiScanner.app" go run ./examples/scan +``` + +## Where to next + +- [How it works]({{ site.baseurl }}/how-it-works) — the embedded + helper, the wire protocol, and why this approach exists at all. +- [GoDoc](https://pkg.go.dev/github.com/jaisonerick/macwifi) — full + type and function reference. +- [`macwifi-cli`](https://github.com/jaisonerick/macwifi-cli) — a + drop-in `airport`-replacement built on this package, if you'd + rather not write Go. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..2ef3456 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,194 @@ +--- +title: How it works +layout: default +nav_order: 3 +description: "Architecture of macwifi: the embedded signed helper, the macOS Location Services and Keychain dance, and the wire protocol between Go and Swift." +permalink: /how-it-works +--- + +# How macwifi works +{: .no_toc } + +A short tour of the architecture, in case you want to know what your +binary is actually doing on the user's machine before you ship it. + +## Table of contents +{: .no_toc .text-delta } + +1. TOC +{:toc} + +--- + +## The constraint + +macOS protects Wi-Fi metadata (specifically BSSIDs and the contents +of nearby-network scans) behind the Location Services privilege. +That privilege is governed by **TCC** (Transparency, Consent, and +Control), the subsystem that draws the *"Foo wants to use your +Location"* dialog and stores the answer in `~/Library/Application +Support/com.apple.TCC`. + +TCC identifies the requesting program by **code signature**. From +[Apple's DTS team][apple-dts]: + +> For TCC to work reliably the calling code must be signed with a +> code signing identity that TCC can use to track the code from build +> to build. +> +> Not unsigned. No ad hoc signed. + +In practice, that means a Go program — which is built locally, +unsigned by default, and changes hash on every build — cannot get the +Location Services privilege. CoreWLAN's `scanForNetworks` happily +returns to your unsigned binary, but every BSSID is empty and many +fields are zero. `wdutil info` does the same: it returns +`BSSID : `. There is no flag, no entitlement, no plist key +that turns this off from a script. + +[apple-dts]: https://developer.apple.com/forums/thread/718331 + +## The workaround + +The only thing TCC understands is a **signed app bundle with a stable +identifier**. So `macwifi` ships one. + +``` +your-binary (unsigned Go program) + │ + │ 1. extract embedded WifiScanner.app to /tmp + │ 2. exec `open -W` on it, with MACWIFI_PORT=NNNN env var + ▼ +WifiScanner.app (Developer-ID-signed, notarized) + │ + │ 3. dial back into your Go process on 127.0.0.1:NNNN + │ 4. run CoreWLAN / Keychain calls, return responses over TCP + ▼ +your-binary (decodes, returns []Network or password string) +``` + +The helper has its own bundle ID and a stable Developer ID +signature, so TCC can track it across runs. The first time it asks +for Location Services, macOS shows the standard prompt; the user +approves, and from then on TCC silently allows the request based on +the signature alone. + +## The components + +### `WifiScanner.app` (Swift) + +A minimal Swift app that: + +- Sets `LSUIElement` so it doesn't appear in the Dock or the + ⌘-Tab switcher. +- Reads `MACWIFI_PORT` and `MACWIFI_PARENT_PID` from its environment. +- Opens a TCP connection back to `127.0.0.1:MACWIFI_PORT` to receive + requests from the parent Go process. +- Watches `MACWIFI_PARENT_PID` via `kqueue` and exits the moment the + parent dies — so a helper stuck in `SecItemCopyMatching` waiting + for the user to answer the Keychain dialog can't outlive the + caller. +- Calls `CWWiFiClient` for scans and `CWKeychainFindWiFiPassword` for + saved Wi-Fi passwords. + +The bundle is signed with a Developer ID Application certificate and +notarized via Apple's notary service. The signing happens +automatically in CI; see the +[`signed-companion.yml`](https://github.com/jaisonerick/macwifi/blob/main/.github/workflows/signed-companion.yml) +workflow. + +### `embedded/WifiScanner.app` (in the Go package) + +The signed-and-notarized bundle is checked into the repo at +`embedded/WifiScanner.app` and pulled into the Go binary via +`go:embed`. On first use, the Go side extracts it to +`os.TempDir()/macwifi-/` and `exec`s `open -W` on it. +Subsequent runs reuse the extracted bundle, so the cost is paid once. + +Developers iterating on the Swift side can override the embedded +bundle with `MACWIFI_APP=$PWD/WifiScanner.app` to point at a locally +built helper without rebuilding the embed. + +### The wire protocol (`protocol.go`) + +A small length-prefixed binary protocol between the Go side and the +helper. Three message types: + +| Type | Direction | Purpose | +| ---- | --------- | -------------------------------------- | +| `0x01` scan request | Go → helper | "Run a scan, send back results." | +| `0x02` scan response | helper → Go | Encoded `[]Network` slice. | +| `0x03` password request | Go → helper | "Look up Keychain password for SSID." | +| `0x04` password response| helper → Go | Password bytes (or empty). | +| `0x05` close request | Go → helper | "Clean shutdown." | + +There is no JSON, no protobuf, no TLS. The protocol speaks across +loopback, the helper exits when the parent dies, and the connection +is short-lived. If the protocol grows, it'll grow in +[`protocol.go`](https://github.com/jaisonerick/macwifi/blob/main/protocol.go). + +## Lifecycle of one scan + +1. Your Go code calls `macwifi.Scan(ctx)`. +2. The package extracts `WifiScanner.app` (cached) and binds a TCP + listener on `127.0.0.1:0`. +3. It runs `open -W /tmp/macwifi-/WifiScanner.app + --env MACWIFI_PORT=NNNN --env MACWIFI_PARENT_PID=...`. +4. macOS launches the helper. If this is the first run on this + machine, the user sees a *"WifiScanner wants to use your + Location"* dialog; nothing else happens until they answer. +5. The helper dials back on `127.0.0.1:NNNN`. The Go side + `Accept`s and now has a live TCP connection to the helper. +6. Go writes a scan-request frame; the helper calls + `CWWiFiClient.shared().interface()?.scanForNetworks(...)`, + serializes the result, and writes a scan-response frame back. +7. Go decodes the frame into `[]macwifi.Network` and returns. +8. On `Close`, Go writes a close-request, the helper exits, and the + socket is torn down. + +If you keep a `*macwifi.Client` open across many calls, steps 1–5 +only happen once. + +## Why not a CGo bridge? + +Skipping CGo and using a separate signed helper is what makes +`macwifi` work at all. CoreWLAN linked into your Go binary via CGo +inherits the Go binary's (lack of) signature. TCC denies it the +Location privilege; you get back redacted, empty data — exactly the +state every existing Go library is in today. + +A separately-signed helper is the only path that satisfies macOS +TCC for an unsigned caller. The helper is small (~200 KB compressed) +and the per-call latency is dominated by the user's network and the +Wi-Fi card's scan time, not by the IPC. The trade-off has been worth +it in practice. + +## Why a Swift app instead of a tiny Objective-C helper? + +Swift is what Apple's CoreWLAN samples target now, the type-checking +caught a couple of Mach-O ABI issues during development, and the +build is just `swiftc` + `codesign` + `notarytool` — nothing exotic. +The helper itself is around 200 lines. + +## What you give up + +- **No `launchd` daemons.** CoreWLAN's Location Services check is + per-user-session, so a system-wide daemon won't work even with + the signed helper. +- **No bypassing the Location Services prompt.** The first scan + always pops the dialog. This is intentional; Apple has explicitly + designed against allowing unattended access. +- **No bypassing the Keychain prompt.** Same applies; the + *Always Allow* button is gone in current macOS, so password lookups + prompt every time. + +These aren't bugs in `macwifi` — they're the user's privacy controls +working as designed. + +## Where to read the source + +- [`macwifi.go`](https://github.com/jaisonerick/macwifi/blob/main/macwifi.go) — the Go API surface (`Client`, `Scan`, `Password`, `New/Close`). +- [`embed.go`](https://github.com/jaisonerick/macwifi/blob/main/embed.go) — extracts the embedded bundle to `os.TempDir`. +- [`protocol.go`](https://github.com/jaisonerick/macwifi/blob/main/protocol.go) — the wire format. +- [`scanner/Sources/`](https://github.com/jaisonerick/macwifi/tree/main/scanner/Sources) — the Swift helper. +- [`.github/workflows/signed-companion.yml`](https://github.com/jaisonerick/macwifi/blob/main/.github/workflows/signed-companion.yml) — the signing + notarization workflow that re-bakes the embedded bundle whenever the Swift side changes. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c881638 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,119 @@ +--- +title: Home +layout: default +nav_order: 1 +description: "Wi-Fi scanning and Keychain password access for Go programs on macOS 13+, after the airport CLI was removed and wdutil started redacting BSSIDs." +permalink: / +--- + +# macwifi +{: .fs-9 } + +Wi-Fi scanning and Keychain password access for Go programs on macOS 13+. +{: .fs-6 .fw-300 } + +[Get started]({{ site.baseurl }}/getting-started){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } +[View on GitHub](https://github.com/jaisonerick/macwifi){: .btn .fs-5 .mb-4 .mb-md-0 } + +--- + +## The problem + +macOS 14.4 removed `/usr/libexec/airport`, the CLI tool that backed +almost every script and library that wanted to enumerate nearby +Wi-Fi networks or get the BSSID, RSSI, and channel of the current +connection. + +Its replacement, `wdutil info`, requires `sudo` and returns +`BSSID : ` for every entry. `networksetup` and `ioreg` +don't expose nearby networks at all. + +The Apple-recommended path is the CoreWLAN framework, but +`scanForNetworks` only returns real BSSIDs to apps that are +**signed with a stable Developer ID** *and* **have been granted +Location Services permission**. Apple's Developer Technical Support +team has confirmed [on the developer forums][apple-dts] that scripts +and `launchd` daemons can't satisfy these requirements: + +> I recommend that you try this with native code built in to a native +> app. TCC, the subsystem within macOS that manages the privileges +> visible in System Preferences > Security & Privacy > Privacy, +> doesn't work well for scripts. + +The Go libraries that previously did this — `schollz/wifiscan`, +`jarethdisley/wifiscanparser`, and several others — parse `airport` +output. None of them work on Sonoma 14.4 or later. + +## What macwifi does + +`macwifi` is a Go package that embeds a Developer-ID-signed, +notarized Swift helper bundle. Your Go binary spawns the helper on +first use to trigger the macOS Location Services prompt; subsequent +calls reuse the same helper process. + +```go +package main + +import ( + "context" + "fmt" + + "github.com/jaisonerick/macwifi" +) + +func main() { + nets, err := macwifi.Scan(context.Background()) + if err != nil { + panic(err) + } + for _, n := range nets { + fmt.Printf("%-32s %s %d dBm ch %d\n", + n.SSID, n.BSSID, n.RSSI, n.Channel) + } +} +``` + +The package returns rich metadata — SSID, BSSID, RSSI, noise floor, +channel number, channel band (2.4/5/6 GHz), channel width, security +mode, PHY mode, and current/saved flags. There's also +`macwifi.Password(ctx, ssid)` for reading saved Wi-Fi passwords from +the Keychain after the user approves the system prompt. + +## Install + +```sh +go get github.com/jaisonerick/macwifi +``` + +Requirements: macOS 13 or newer on Apple Silicon, Go 1.26+. + +## Just want a CLI? + +If you don't need a Go API and just want a working `airport -s` +replacement on the terminal, install +[`macwifi-cli`](https://github.com/jaisonerick/macwifi-cli): + +```sh +go install github.com/jaisonerick/macwifi-cli@latest + +macwifi-cli scan +macwifi-cli info +macwifi-cli password "MyHomeWiFi" +``` + +## What's next + +- [Getting started]({{ site.baseurl }}/getting-started) — full setup, + Location Services dance, troubleshooting, and a longer worked + example. +- [How it works]({{ site.baseurl }}/how-it-works) — what the embedded + helper does, why it exists, and the protocol it speaks back to Go. + +## Scope + +`macwifi` is a macOS-only Go library. It is not a cross-platform +abstraction, a packet capture library, a background daemon, or a +workaround for macOS privacy controls. Location Services and Keychain +prompts still go through the user, every time. + +[apple-dts]: https://developer.apple.com/forums/thread/718331