README
¶
macswitcher
macswitcher switches macOS network contexts and keeps DNS, Unbound, proxy,
Alpaca, Kerberos, and application lifecycle settings together for each location.
Build
go build -o macswitcher ./cmd/macswitcher
Requires macOS, Go 1.23+, alpaca, and optionally Docker Desktop and Unbound.
Install
brew install schretzi/tap/macswitcher
(published to schretzi/homebrew-tap by the release pipeline; see Releasing below)
Configuration
Installation is managed by the MacbookSetup Ansible repository. The CLI reads the linked global file and context files from:
~/.config/macswitcher/config.yaml
~/.config/macswitcher/contexts/home.yaml
~/.config/macswitcher/contexts/remote.yaml
~/.config/macswitcher/contexts/work.yaml
config.yaml contains app-global settings such as the local proxy,
network-service defaults, application lifecycle commands (including Docker),
and the shared Alpaca binary and command. Context files contain only
location-specific overrides and references to those named applications. The
private and work overlays supply their own context files; the public repository
contains only generic defaults and an empty work starter.
mac_network_location: Automatic
dns:
network_services: []
resolvers:
- 127.0.0.2
# Optional. The name a switch resolves to decide whether DNS came up.
check_host: intranet-host.example.com
# Optional. DNS search list, completing single-label names.
search_domains:
- corp.example.com
proxy_mode: direct
apps:
reload:
- unbound
dns.check_host overrides the name a switch resolves to prove DNS is working
before it goes any further. The defaults are the forward proxy's own hostname
in forward mode and google.com otherwise, and both assume the network
resolves public names — which a corporate network reached over a VPN need not
do. Where its resolvers serve the intranet only, google.com tests something
the network was never going to answer, and a switch that in fact worked is
reported as failed. Point check_host at an intranet name that is always
resolvable on the network the context describes. An explicit value wins over
the forward proxy's hostname.
dns.search_domains sets the DNS search list, which completes single-label
names. A corporate PAC may nominate its proxy by short name — PROXY proxy:8080 — and nothing but the search list can turn that into a resolvable
address. Without it every request through that proxy fails, and because the
failure is reported by the local proxy rather than by DNS it arrives as a
502 Bad Gateway, naming the wrong culprit entirely.
The list is applied on every switch, including when a context names none: in that case it is cleared. This is deliberate. A search domain that outlived the context needing it goes on completing short names against a network the machine has since left, which resolves them to nothing or, worse, to something unintended on the current network.
The global and context configuration files use YAML. Runtime context selection
is kept separately in state.json; Docker's own ~/.docker/config.json also
remains JSON. The global file contains shared local-proxy, Alpaca, DNS, Unbound,
and application commands. Context files contain the active mode,
optional forwarder_proxy settings, and application action lists. Actions are
explicit: start, stop, restart, and reload. A reload is used when the
application supports hot reloading; the switcher does not automatically stop
and start applications for every context change. If an application has no
explicit restart or reload command, but has both stop and start
commands, those are used as a fallback.
The launchd environment
Setting the proxy also publishes it into the user's launchd GUI domain with
launchctl setenv, alongside the system, shell and Docker settings.
This exists because launchd does not source the shell's rc files. A LaunchAgent
starts with an environment holding little more than PATH, so the
~/.zsh/rcs/proxy file every interactive shell reads is invisible to it — an
agent that works perfectly when you run it by hand fails as a background job,
which is a confusing way to find out.
It is worth knowing what that failure looks like. A Go program whose transport
has no proxy resolves the target host itself; with a proxy it never resolves
the name at all and hands it to the proxy instead. So a missing proxy surfaces
as lookup oauth2.googleapis.com: no such host — a DNS error for a problem
that has nothing to do with DNS.
launchctl setenv only reaches processes started after it runs, and Go
caches the environment on its first http.ProxyFromEnvironment call, so a
running agent cannot pick up a change either way. Agents that consume the proxy
therefore have to be restarted, and are named in local_proxy.restart_agents:
local_proxy:
host: 127.0.0.1
port: 3128
restart_agents:
- tunneling
Each entry names an applications key and is restarted after the proxy is
applied. That is deliberately not a context's apps.restart list: those run
early in a switch, before DNS and long before the proxy, so an agent restarted
there would inherit the environment of the context being left. Nor does the
list vary by context — an agent that consumes the proxy needs the restart in
every context, including the one that turns the proxy off.
Bypassing the proxy
There are four independent places a host can be excluded from the proxy, and they do not use the same syntax or apply in the same modes. Getting one of them right is usually not enough.
| layer | reaches | applies in |
|---|---|---|
NO_PROXY (shell, Docker, launchd) |
CLI tools and anything using Go/curl conventions | every mode |
| macOS bypass list | GUI applications and browsers | every mode |
filter_proxy.direct |
traffic through the filtering proxy | direct mode only |
| the upstream PAC | traffic alpaca forwards | forward mode |
Both NO_PROXY and the macOS bypass list are generated from a single
local_proxy.no_proxy list, because keeping two hand-maintained lists in step
is a losing game.
The one that catches people out is filter_proxy.direct. The generated filter
PAC is only in the path in direct mode (filterProxyAppliesTo) — in
forward mode alpaca reads the corporate PAC instead, so an entry added
there has no effect under VPN. It is the right place for hosts that must skip
TLS inspection, and the wrong place for hosts that must skip the proxy.
That is why the macOS bypass list is managed here rather than left to the upstream PAC: it works in every mode, needs no cooperation from a PAC file nobody controls, and preserves alpaca's automatic PAC refresh.
Note the two syntaxes differ in a way that fails quietly. Go matches
no_proxy entries against domain labels, so kiac covers kiac and
*.kiac — but not gtt.apps.main.kiac.example.net, which needs its own
.kiac.example.net entry. networksetup matches literally unless there is a
*, so each name is written out in both forms.
proxy_mode accepts exactly three values:
off— removes all proxy configuration (system network services, the macOS bypass list, the~/.zsh/rcs/proxyshell env file, Docker's~/.docker/config.json, and the launchd environment) and stops the local proxy service.direct(default) — points system settings at the local proxy, and the local proxy reaches the internet directly. Anyforwarder_proxyblock is ignored in this mode.forward— points system settings at the local proxy, and the local proxy forwards requests to the upstream proxy defined in the requiredforwarder_proxyblock.macswitcher config validatefails ifproxy_mode: forwardis set without aforwarder_proxyblock.
Forwarder credentials are either read from the macOS Keychain (NTLM/Basic) or taken from a Kerberos ticket file — see below.
A forwarder_proxy block belongs to a context file, for example
~/.config/macswitcher/contexts/work.yaml. proxy_server and port are
always required and identify the upstream proxy macswitcher forwards to; the
rest of the block depends on how that proxy authenticates.
NTLM/Basic (Keychain-backed password):
proxy_mode: forward
forwarder_proxy:
proxy_server: proxy.example.com
port: 8080
username: jdoe
password_keychain_service: macswitcher-work-proxy
password_keychain_account: jdoe
pac_file: https://proxy.example.com/proxy.pac
auth_allowlist:
- proxy.example.com
usernameandpassword_keychain_serviceare required; the password itself is never stored in the config and is read from Keychain at runtime (seeproxy password-setbelow).password_keychain_accountis optional and defaults tousernamewhen omitted.pac_fileis optional and, if set, points to the PAC URL to configure for this context.auth_allowlistis optional but strongly recommended: it restricts which proxy hosts may receive the stored credentials, even if a PAC file selects a different host. Leaving it empty, or using*, is permissive and triggers aconfig validatewarning.
Kerberos/Negotiate: configure the ticket location once in KerberosKeepAlive.
alpaca authenticates to the forward proxy by trying Negotiate, then NTLM, then
Basic, and drops any method it has no credentials for. macOS GSS uses its
default credential cache, while KerberosKeepAlive deliberately refreshes the
ccache_path named in its own profile. In forward contexts macswitcher passes
that path as KRB5CCNAME=FILE:<ccache_path> to alpaca, so its Negotiate token
uses the ticket KKA actually maintains. Keep the ticket alive with
KerberosKeepAlive and it is used automatically.
macswitcher's part is the last rung: it reads the forwarder_proxy password
from the Keychain and passes it to alpaca as BASIC_CREDENTIALS. Without it a
context whose ticket has expired — or whose KDC cannot be discovered — has no
usable authentication method at all, and every request through the proxy
fails. Basic is slower, authenticating per request, and weaker, which is why
it is last and not first.
The password is passed through the environment rather than on the command
line: argv is world-readable via ps, an environment is not (ps -E shows
another user's environment only to root).
There is no ticket_file setting. It used to exist and conflated two separate
questions — where the ticket lives, and whether the proxy authenticates with
it. observe and alpaca's KRB5CCNAME both read the location from
KerberosKeepAlive's own ccache_path, which is the thing that creates the
file.
cntlm is likewise gone, along with the {{cntlm_conf}} template token. alpaca
speaks NTLM directly; there is nothing left for a bridge to do.
The public config contains no environment-specific IPs, hostnames, usernames, PAC URLs, or proxy credentials. Put those values in the private/work overlays. Secrets (Keychain passwords and Kerberos ticket files) are never configuration file values.
For a machine not managed by Ansible, macswitcher config init creates a
home context from the current macOS network location, network services, DNS
resolver, and readable Unbound forwarders, plus an intentionally empty work
context.
Unbound forwarders are managed at
/opt/homebrew/etc/unbound/conf.d/forwarders.conf. Ansible initially links
this path to its default Dotfiles/unbound/forwarders.conf. Before writing
context-specific forwarders, macswitcher removes only that symlink and creates
a real runtime-managed file, so the repository source is never modified.
Store forwarder proxy credentials in Keychain:
./macswitcher proxy password-set
Validate all global and context files with ./macswitcher config validate.
Commands
./macswitcher switch home
./macswitcher switch remote
./macswitcher switch work
./macswitcher status
./macswitcher proxy set
./macswitcher proxy unset
./macswitcher proxy detect-auth --proxy '<proxy-host>:<port>'
./macswitcher service install
./macswitcher service start
./macswitcher service restart
./macswitcher service status
./macswitcher observe
Legacy aggregate configuration files are read for migration; the next write
stores contexts under contexts/.
What switch actually does
macswitcher switch <context> runs, in order:
- Persist the selected context as current (survives even if later steps warn/fail).
scselectthe context's macOS network location, if set (warns, doesn't abort, on failure).- Rewrite
unbound.forwarders_filefrom the context'sunbound_forwarders, if any. - If forwarders were rewritten, restart unbound via
applications.unbound.restart(warns if that's not configured — a staleforwarders.confload is a common source of "it takes forever after switching" symptoms). - Point the network services' DNS servers at
dns.local_resolver(or the context'sdns.resolversoverride). - Flush the system DNS cache (
dscacheutil -flushcache+killall -HUP mDNSResponder, both viasudo -n— see below). - Verify DNS actually works before touching the proxy: resolve
google.comforoff/directmodes, or the forward proxy's ownproxy_serverhostname forforwardmode. If this fails,switchstops here with an error and a hint to fix DNS and rerun — none of the remaining steps (proxy service, app sync) can work with broken DNS anyway. - Stop (if
proxy_mode: off) or restart (otherwise) the Alpaca service, then set or unset the local proxy accordingly. - Run the context's
apps.stop/restart/reload/starthooks.
Steps 4 and 6 shell out to sudo -n ... (non-interactive), so they need
matching passwordless-sudo sudoers entries, e.g.:
your-user ALL=(root) NOPASSWD: /usr/bin/dscacheutil -flushcache
your-user ALL=(root) NOPASSWD: /usr/bin/killall -HUP mDNSResponder
your-user ALL=(root) NOPASSWD: /bin/launchctl kickstart -k system/net.unbound
Without these, steps 4/6 just print a warning and switch continues; step 7
will then fail fast if DNS genuinely isn't working yet.
Observe
macswitcher observe opens a terminal UI (built with
Bubble Tea) showing the live
status of every daemon macswitcher cares about, refreshed automatically every
5 seconds:
- alpaca — macswitcher's own launch agent (
com.schretzi.macswitcher, installed byservice install). Shows running/stopped, pid, uptime, the launchd restart counter (runs), and the active context'sproxy_modeplus, inforwardmode, the upstream host:port and whether auth is Keychain- or Kerberos-based. - unbound — shows running/stopped and the
forward-addrentries currently inunbound.forwarders_file. - kerberoskeepalive — shows running/stopped and, for the
ccache_pathofKerberosKeepAlive's first profile, whetherklistreports a valid, non-expired ticket. An invalid ticket is reported as survivable, because alpaca falls back to Basic against the forward proxy. - omt — shown only if the
omtbinary is onPATH; runsomt statusand summarizes how many configured OAuth2 accounts have a valid token. - vpn — a generic LaunchAgent-supervised VPN connection (e.g. an
openconnectwrapper script). Shows running/stopped, and, ifdaemons.vpn.interfaceis set, whether that tunnel interface currently has an address (a running supervisor process doesn't guarantee the tunnel actually came up). - tunneling — shown only if the
tunnelingbinary is onPATH; runstunneling statusand summarizes how many of the configured SSH/GCP-IAP tunnels have an open local port. Listed last because its tunnels ride on whatever the rows above set up, so a failure here is usually a symptom of one of them.
unbound, KerberosKeepAlive, omt, vpn, and tunneling are not installed by macswitcher (they come from Homebrew or an external Ansible role/script), so their launchd labels must be configured explicitly:
daemons:
unbound:
label: homebrew.mxcl.unbound # this is the default if omitted
kerberos_keep_alive:
label: com.example.kerberoskeepalive
omt:
label: com.schretzi.omt
vpn:
label: com.example.vpn
interface: utun99 # optional: enables the tunnel-up check
tunneling:
label: com.schretzi.tunneling
Any daemon whose label is empty (the default for kerberos_keep_alive,
omt, vpn, and tunneling) is shown as "not configured" and cannot be
controlled from the TUI.
macswitcher config validate checks the daemons: block for unrecognized
keys or fields (a common source of silent typos, since unknown YAML keys are
otherwise just ignored).
By default each daemon is assumed to be a per-user LaunchAgent, loaded in the
gui/<uid> domain from ~/Library/LaunchAgents/<label>.plist. Some daemons
— notably a system-wide unbound install — instead run as a LaunchDaemon in
the system domain from /Library/LaunchDaemons/<label>.plist. Set
scope: system for those:
daemons:
unbound:
label: net.unbound
scope: system
Inspecting a system-scoped daemon's status never needs elevated privileges,
but starting, stopping, restarting, enabling, or disabling one does: those
actions run sudo launchctl ... interactively, suspending the TUI and
handing the real terminal to sudo so it can prompt for your password, then
resuming once it exits.
Keys: ↑/↓ or j/k to select a row, s start, S stop, R restart,
e enable, d disable, r to refresh immediately, q/Esc/Ctrl-C to
quit. Start/stop map to launchctl bootstrap/bootout (load state right
now); enable/disable map to launchctl enable/disable (a persisted
override independent of whether the agent is currently loaded, so a
disabled agent stays off across reboots even with RunAtLoad set).
Development
lefthook install # one-time per clone: enables the gitleaks pre-commit hook
make pipeline # fmt, lint, security (govulncheck + gosec), test, build
make docs # regenerate command docs under docs/
CI (.github/workflows/ci.yml) mirrors make pipeline with separate test,
lint, security, and build jobs on every push and pull request to main.
Releasing
Releases are built with goreleaser and are manual-only, never automatic on tag push:
git tag vX.Y.Z
git push origin vX.Y.Z
Then go to Actions → Release → Run workflow, selecting that tag. This builds
darwin binaries, publishes a GitHub release with checksums and per-archive
SPDX SBOMs (generated by syft),
and updates the Homebrew cask in
schretzi/homebrew-tap (requires
a HOMEBREW_TAP_TOKEN repo secret with write access to that tap repo).
AI usage
Parts of this project's scaffolding (CI pipeline, goreleaser config, Makefile,
lefthook/gitleaks hook, and Cobra doc generator) were added with GitHub
Copilot CLI. Architecture and tool choices (GitHub Actions, goreleaser, gitleaks, Homebrew tap)
were decided jointly after researching the actual behavior of each tool
locally; the generated configuration was verified end-to-end by running the
local pipeline (make pipeline), a goreleaser snapshot build, and a live
gitleaks pre-commit test, rather than assumed to work.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
macswitcher
command
|
|
|
internal
|
|
|
logfile
Package logfile provides an append-mode log writer that survives an external rotation.
|
Package logfile provides an append-mode log writer that survives an external rotation. |
|
service
Package service manages this tool's own macOS LaunchAgent: the plist that runs it in the background, and the launchctl calls that load, unload and inspect it.
|
Package service manages this tool's own macOS LaunchAgent: the plist that runs it in the background, and the launchctl calls that load, unload and inspect it. |
|
version
Package version reports build information and provides the `version` subcommand.
|
Package version reports build information and provides the `version` subcommand. |
|
tools
|
|
|
gendocs
command
Command gendocs generates Markdown documentation for the macswitcher CLI command tree into the docs/ directory.
|
Command gendocs generates Markdown documentation for the macswitcher CLI command tree into the docs/ directory. |