DEV Community

Jaison Erick
Jaison Erick

Posted on

Reading Wi-Fi data from Go on macOS after Apple removed `airport`

If you've ever needed your script or your Go program to read the SSID, BSSID, signal strength, or channel of the Wi-Fi network a Mac is on, the last two years have not been kind.

I'd had a small inventory tool quietly working since 2022. It shelled out to airport -s, parsed the output, and uploaded a row to a database. Last year, after I upgraded a fleet of MacBooks to Sonoma 14.4, every row was empty. I spent two evenings figuring out why, found a wall I couldn't climb over with a script, and ended up shipping a small library to climb under it. This is the writeup.

The wall

airport — the CLI tool that almost every Mac script used for Wi-Fi info — was removed in macOS Sonoma 14.4. Apple's deprecation notice tells you to use wdutil instead.

wdutil requires sudo, and even with sudo it gives you this:

$ sudo wdutil info | grep BSSID
BSSID    : <redacted>
Enter fullscreen mode Exit fullscreen mode

Same story for SSID and MAC address. They all come back <redacted>. This was an intentional change starting in macOS 14.5 — Apple has decided that Wi-Fi metadata is location data and is locking it down.

networksetup -getairportnetwork still returns the SSID on macOS 13 and 14, but not the BSSID, and Apple disabled it in macOS 15. ioreg no longer surfaces the values either. system_profiler SPAirPortDataType gives you signal/noise for the current connection but not nearby networks, and the BSSID column is empty without the right permissions.

The only Apple-blessed path is the CoreWLAN framework's scanForNetworks(...) method:

- (NSSet<CWNetwork *> *)scanForNetworksWithName:(NSString *)networkName
                                  includeHidden:(BOOL)includeHidden
                                          error:(NSError * _Nullable *)error;
Enter fullscreen mode Exit fullscreen mode

Two problems with that, from a Go program's perspective:

  1. You need to call it from Objective-C / Swift. That means either CGo or shelling out to a Swift helper.
  2. It only returns real BSSIDs to apps that have been granted Location Services permission, and only to apps signed with a stable Developer ID code-signing identity. From a script, an ad-hoc-signed binary, or even a launchd daemon, it returns nil BSSIDs.

The second one is the killer. From the Apple Developer Forums, Apple's own DTS team:

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.

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.

Via a launchd daemon — That's unlikely to work. CoreWLAN checks for the Location privilege and that's hard for a daemon to get.

So: even if I CGo'd CoreWLAN into my Go binary, the binary itself would need a Developer ID code signature for TCC to grant the Location privilege. Go binaries are unsigned by default and change hash on every build, which means TCC will never persistently authorize them.

This is the wall. There's no flag, no entitlement, no plist key that turns it off.

The realization

What TCC does understand is a separately-signed app bundle with a stable identifier. That's the loophole — not "bypass macOS's controls", but "play by macOS's rules with a different binary".

The pattern is:

  1. Your Go binary (unsigned, fine) ships a small companion app bundle inside it.
  2. The bundle is signed once with a Developer ID Application certificate and notarized via Apple's notary service.
  3. At runtime, your Go binary extracts the bundle to a temp directory and execs open -W on it.
  4. The bundle is what asks for the Location Services permission. It has a stable code signature, so TCC happily tracks the authorization across runs.
  5. The bundle does the CoreWLAN call, serializes the result, and passes it back to the Go side over a small loopback socket.

That's it. Once the user has approved the Location Services prompt once for the bundle, every subsequent Scan() from your Go program returns real BSSIDs.

The library

I packaged this pattern as macwifi. It's a Go library that ships the Developer-ID-signed and notarized helper bundle inside the package via go:embed. Calling code looks like this:

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  %s\n",
            n.SSID, n.BSSID, n.RSSI, n.Channel, n.Security)
    }
}
Enter fullscreen mode Exit fullscreen mode

After the user approves the macOS Location Services prompt the first time, that prints:

Office Wi-Fi                     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
Enter fullscreen mode Exit fullscreen mode

The Network struct carries everything CoreWLAN exposes that a Go developer is likely to want: SSID, BSSID, RSSI, noise floor, channel number, channel band (2.4 / 5 / 6 GHz), channel width, security mode (open / WEP / WPA / WPA2 / WPA3 / Enterprise / OWE), PHY mode, plus Current and Saved flags.

There's also macwifi.Password(ctx, ssid) for reading saved Wi-Fi passwords from the System keychain. That uses CWKeychainFindWiFiPassword and triggers the macOS Keychain Allow / Deny dialog. (The legacy Always Allow button is gone in current macOS, so the prompt fires every time you call it. That's deliberate on Apple's part.)

If you'd rather not write Go and just want a working airport -s replacement on the terminal, the same package powers a small CLI:

brew install jaisonerick/tap/macwifi-cli

macwifi-cli scan
macwifi-cli info             # current network only
macwifi-cli password "MyWiFi"
macwifi-cli scan --json | jq '.[] | select(.rssi > -65)'
Enter fullscreen mode Exit fullscreen mode

What you give up

This isn't a free workaround. Some honest scope notes:

  • macOS 13+ on Apple Silicon only. Intel Macs aren't supported. No plans to.
  • Doesn't work from a system-wide launchd daemon. CoreWLAN's Location Services check is per-user-session, so the helper bundle needs at least one user logged in. Works fine from a launchd agent or any program a user runs themselves.
  • The Location Services prompt always fires once. There's no API to suppress it; that's the entire point.
  • The Keychain prompt fires every time you call Password() for a given SSID, because Always Allow no longer exists.
  • Not a packet capture library. For sniffing, Wireshark and Wireless Diagnostics' Sniffer Mode are still the right tools.

What I learned

The macOS-vs-power-users story keeps repeating itself: Apple closes a door, the workaround that kept everyone happy for 15 years stops working, and there's exactly one path that's still open if you're willing to put a small signed binary in your build pipeline. It's worth knowing the shape of that path, even if you never need it yourself, because the same pattern shows up everywhere TCC is involved (Full Disk Access, Accessibility, Screen Recording).

For Wi-Fi specifically, the pattern is now baked into a library, so the next person who needs to read a BSSID from a Go program on a fleet of MacBooks doesn't have to spend two evenings rediscovering the constraint.

Links

If you've hit the same wall and want to compare notes — or if you're on Apple's side of this and have a cleaner path I missed — the issue tracker is open.

Top comments (0)