VPN Coexistence
When a QA engineer runs Ghost on their Mac while connected to a corporate VPN (like Palo Alto GlobalProtect, Cisco AnyConnect, or Zscaler), things break. The VPN daemon silently overrides Ghost’s proxy settings, sending traffic past Ghost — or worse, pointing traffic at Ghost’s port after Ghost has been closed, breaking all connectivity. This page explains why this happens and how Ghost’s 5-layer system solves it.
This is a macOS-specific problem. Windows and Linux don’t have the same proxy architecture, and their VPN clients don’t compete for proxy control the same way.
The Problem
Section titled “The Problem”To understand the problem, you need to know how macOS decides which proxy to use. There are two separate layers of proxy configuration, and one always wins:
What this diagram shows — why VPNs silently kill proxy tools:
macOS has two layers of proxy configuration. The Setup layer (green, left) is what you configure through System Preferences or command-line tools like networksetup. These settings are stored in preferences.plist — they’re persistent and survive reboots. The State layer (red, right) is an in-memory runtime override maintained by configd (macOS’s configuration daemon). Daemons write to this layer using the SCDynamicStore API.
Here’s the critical part: State keys ALWAYS override Setup keys. When both exist for the same network service, macOS ignores the Setup layer entirely and uses the State layer.
Ghost uses networksetup (the standard tool) to set proxy configuration, which writes to the Setup layer. GlobalProtect’s daemon (gpd.pan) writes directly to the State layer. So Ghost sets its proxy in Setup, GlobalProtect overrides it in State, and scutil --proxy (what applications actually use to find the proxy) shows GlobalProtect’s settings — not Ghost’s.
Two Failure Scenarios
Section titled “Two Failure Scenarios”Scenario 1: Ghost is running, then closes.
GlobalProtect’s State key may still point to Ghost’s proxy address (127.0.0.1:4545). Since State overrides Setup, applications try to connect through Ghost’s port — but Ghost isn’t running anymore. All web traffic breaks. The user has to manually disable the proxy or restart the VPN.
Scenario 2: VPN connects while Ghost is running.
Ghost’s networksetup commands successfully write to the Setup layer, but GlobalProtect immediately writes State keys that override them. Even without an explicit Proxies State key, VPN DNS/IPv4/IPv6 State keys cause macOS to scope Setup proxy keys per-interface rather than globally — making scutil --proxy return empty. Ghost thinks it’s the system proxy, but no traffic arrives.
The 5-Layer Solution
Section titled “The 5-Layer Solution”Ghost uses five complementary mechanisms to maintain proxy control across all scenarios.
Layer 1: networksetup (Setup Keys)
Section titled “Layer 1: networksetup (Setup Keys)”This is the standard approach — what every proxy tool uses. Ghost’s EnableWithPAC() configures every non-tunnel network service:
Enable commands (per service):
| # | Command | Purpose |
|---|---|---|
| 1 | networksetup -setwebproxy <service> 127.0.0.1 4545 | Set HTTP proxy address |
| 2 | networksetup -setsecurewebproxy <service> 127.0.0.1 4545 | Set HTTPS proxy address |
| 3 | networksetup -setwebproxystate <service> on | Enable HTTP proxy |
| 4 | networksetup -setsecurewebproxystate <service> on | Enable HTTPS proxy |
| 5 | networksetup -setautoproxyurl <service> http://127.0.0.1:5565/proxy.pac | Set PAC URL for automatic proxy configuration |
| 6 | networksetup -setproxyautodiscovery <service> off | Disable WPAD (Web Proxy Auto-Discovery) — prevents the system from trying to find a proxy via DNS |
Ghost lists all network services via networksetup -listallnetworkservices, skips disabled services (lines starting with *), and skips tunnel services (see Layer 5). If at least one service is configured successfully, the operation is considered successful.
Disable commands (per service) — critical ordering:
| # | Command | Why This Order Matters |
|---|---|---|
| 1 | networksetup -setwebproxy <service> "" "0" | Clear HTTP proxy address first |
| 2 | networksetup -setsecurewebproxy <service> "" "0" | Clear HTTPS proxy address |
| 3 | networksetup -setwebproxystate <service> off | Turn off HTTP proxy state AFTER clearing address |
| 4 | networksetup -setsecurewebproxystate <service> off | Turn off HTTPS proxy state AFTER clearing address |
| 5 | networksetup -setautoproxyurl <service> "" | Remove PAC URL |
| 6 | networksetup -setautoproxystate <service> off | Disable auto proxy |
| 7 | networksetup -setproxyautodiscovery <service> on | Re-enable WPAD |
The order is critical because networksetup -setwebproxy has a side effect: it implicitly re-enables the proxy state. If you turn off the state first (step 3) and then clear the address (step 1), the state gets turned back on — pointing at Ghost’s now-dead port. By clearing the address first (setting port to 0), even if the state gets re-enabled, it’s harmless.
Disable runs on ALL services (not just non-tunnel), because Ghost may have been enabled on services that are now tunnel services (VPN connected after Ghost started).
Layer 2: SCDynamicStore State Override
Section titled “Layer 2: SCDynamicStore State Override”When Layer 1 isn’t enough — scutil --proxy doesn’t show Ghost’s settings because a VPN daemon wrote a State key — Ghost writes its own State key, fighting fire with fire.
Detection (DetectVPNStateOverride):
- Check if
scutil --proxyalready shows Ghost’s settings (HTTPEnable: 1, HTTPProxy: 127.0.0.1, HTTPPort: 4545). If yes, nothing to do. - List all State proxy keys:
echo "list State:/Network/Service/.*/Proxies" | scutil - For each key, read its contents and check for problems:
- Ghost’s address with proxy disabled (
HTTPPort: 4545butHTTPEnable: 0) - WPAD enabled (
ProxyAutoDiscoveryEnable: 1) — VPN wants automatic discovery
- Ghost’s address with proxy disabled (
- If a problematic key is found, return it for overriding
- If no existing proxy State key is found, do a second scan for any VPN State keys (DNS, IPv4, IPv6) and check if the service ID matches known tunnel patterns or
gpd.pan(GlobalProtect’s daemon). If a VPN service exists but has no Proxies State key, return a key to be created.
Override (OverrideVPNState):
Ghost writes its proxy settings directly into the VPN’s State key using scutil:
d.initd.add HTTPEnable # 1d.add HTTPPort # 4545d.add HTTPProxy 127.0.0.1d.add HTTPSEnable # 1d.add HTTPSPort # 4545d.add HTTPSProxy 127.0.0.1d.add ProxyAutoConfigEnable # 1d.add ProxyAutoConfigURLString http://127.0.0.1:5565/proxy.pacd.add ProxyAutoDiscoveryEnable # 0set State:/Network/Service/<vpn-service-id>/ProxiesThis requires root privileges. Ghost uses osascript -e 'do shell script "..." with administrator privileges' which displays macOS’s native password dialog (the familiar “wants to make changes” prompt). The timeout for this dialog is 60 seconds — the user needs time to read the dialog and enter their password.
Restore (RestoreVPNState):
On shutdown, Ghost removes its State key override. It doesn’t try to restore the VPN’s original State value — GlobalProtect will re-establish its own State key on its next poll cycle. Ghost first tries sudo -n (non-interactive, uses cached credentials from the previous password prompt). If that fails, it falls back to the osascript admin dialog with a 30-second timeout.
Reapply (ReapplyVPNOverride):
Called by the watchdog when GlobalProtect refreshes its State keys mid-session (overwriting Ghost’s override). Uses sudo -n first (silent, no dialog), falls back to osascript (60-second dialog) if needed.
Layer 3: Stale Key Cleanup
Section titled “Layer 3: Stale Key Cleanup”CleanupStaleStateKeys runs at startup before enabling the proxy:
- Lists all State proxy keys via
scutil - Reads each key’s contents
- If a key contains Ghost’s address (
127.0.0.1:4545), it’s a leftover from a previous session that wasn’t cleaned up (crash, force-quit, power loss) - Removes the stale key via
sudo -n scutil
This handles the scenario where Ghost crashed or was force-quit and never ran its shutdown cleanup. Without this, the stale State key would point traffic at a dead port.
Layer 4: Network Loss Recovery
Section titled “Layer 4: Network Loss Recovery”When the user disconnects from Wi-Fi, unplugs ethernet, or the VPN tunnel drops, Ghost needs to handle the transition gracefully. Previous versions fought with macOS during network transitions by repeatedly calling networksetup, which corrupted proxy state.
What this diagram shows — the network watchdog state machine:
When the network is Online, the watchdog checks every 5 seconds if the proxy settings are still correct (drift detection). If they’ve changed (VPN overwrote them, user manually disabled them), Ghost repairs the configuration. When the network goes Offline, Ghost immediately disables the system proxy and stops checking — repeatedly calling networksetup while interfaces are transitioning causes macOS to corrupt its proxy configuration. When the network comes back Online, Ghost flushes stale connections, waits 2 seconds for the network stack to stabilize, and re-enables the proxy.
Network detection (IsNetworkAvailable):
A zero-IO check that never makes DNS lookups or HTTP requests:
- Enumerate all network interfaces via
net.Interfaces() - Skip interfaces that are not UP or are loopback
- For each remaining interface, check its assigned addresses
- Look for any IPv4 address that isn’t link-local (
169.254.x.x) - Return
trueif found — the machine has at least one routable IP
Edge detection:
The watchdog detects transitions, not just states:
| Transition | Action |
|---|---|
| Online → Offline | Disable system proxy immediately. Set wasNetworkDown flag. Skip all drift checks while down. |
| Offline → Online | Call ResetConnections() to flush stale HTTP/1.1 + HTTP/2 connections + clear per-host protocol cache. Wait 2 seconds. Re-enable system proxy + PAC. Clear wasNetworkDown flag. |
Connection flushing (ResetConnections):
When the network recovers, existing connections are stale (TCP sockets are dead). Ghost flushes three things:
- HTTP/1.1 idle connections (
h1.CloseIdleConnections()) - HTTP/2 idle connections (
h2.CloseIdleConnections()) - Per-host protocol cache (
known map[string]bool) — clears the map that remembers whether each host supports h2 or h1-only. This is important because the protocol support of a host may differ across network transitions (different ISP, different VPN tunnel).
Layer 5: Tunnel Service Filtering
Section titled “Layer 5: Tunnel Service Filtering”All proxy operations skip services that match tunnel keywords. This prevents Ghost from configuring its proxy on VPN tunnel interfaces (which would route VPN traffic through Ghost, breaking the VPN).
Exact keyword list (case-insensitive substring match):
tunnel, vpn, globalprotect, anyconnect, wireguard, wss agent, zscaler, forcepoint, pulse secure, openconnect, fortinet, cisco, ipsec, l2tp, pptp, ike
Additionally, the GlobalProtect service ID gpd.pan is checked separately during VPN State detection.
Startup Sequence
Section titled “Startup Sequence”When Ghost starts in system_proxy mode:
- Start proxy server on
:4545and API server on:5565 - Cleanup —
CleanupStaleStateKeys("127.0.0.1", port)— remove leftover State keys from crashes - Enable —
EnableWithPAC("127.0.0.1", port, apiPort)— configurenetworksetupon all non-tunnel services - Detect —
DetectVPNStateOverride("127.0.0.1", port)— check ifscutil --proxyreflects Ghost - Override — if a VPN State key was detected,
OverrideVPNState()— native password dialog - Watchdog — started when the proxy is activated via the API (
POST /api/v1/proxy/start), runs every 5 seconds
Shutdown Sequence
Section titled “Shutdown Sequence”When Ghost is stopped (SIGINT, SIGTERM, or via UI):
- Restore —
RestoreVPNState()— remove State key override (sudo -n, fallbackosascript) - Disable —
sysproxy.Disable()— clear proxy on all services (address-first ordering) - Stop proxy — drain active connections (10-second timeout)
- Flush — drain buffered security findings and tag updates
- Stop API — shut down the HTTP API server
When the proxy is stopped via the API (POST /api/v1/proxy/stop):
- Stop watchdog — cancel the 5-second ticker goroutine
- Restore —
RestoreVPNState()— remove State key override - Disable —
sysproxy.Disable()with 3 retries, 500ms between — handles transientnetworksetupfailures during interface transitions - Stop proxy — shut down the proxy server
Watchdog Tick (Every 5 Seconds)
Section titled “Watchdog Tick (Every 5 Seconds)”Each tick of the watchdog goroutine:
- Guard — if the proxy isn’t running, exit the goroutine
- Check proxy mode — read config to see if
system_proxymode is active - Network check —
IsNetworkAvailable()→ detect down/up edge transitions - If offline — disable system proxy, set
wasNetworkDown, skip remaining checks - If online after being offline — flush connections, wait 2s, re-enable proxy
- Drift detection (only in system_proxy mode) —
AllServicesHealthy()checks every non-tunnel service has the correct proxy address. If any service has drifted, re-runEnableWithPAC(). - Effective proxy check (only in system_proxy mode) —
IsEffectiveProxySet()runsscutil --proxyto check the resolved proxy. If Ghost’s settings aren’t there:- If Ghost already has a VPN override key (from startup), call
ReapplyVPNOverride()(silentsudo -nfirst) - If no existing override (VPN connected mid-session), run full detection + override cycle
- If Ghost already has a VPN override key (from startup), call
Windows and Linux
Section titled “Windows and Linux”VPN coexistence is a macOS-specific problem. On other platforms:
Windows
Section titled “Windows”Ghost sets the proxy via the Windows registry:
- Key:
HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings - Values:
ProxyEnable(DWORD 1/0),ProxyServer(stringhost:port),ProxyOverride(bypass list:localhost;127.0.0.1;::1;<local>) - After changing: notifies applications via
InternetSetOptionWwithINTERNET_OPTION_SETTINGS_CHANGEDandINTERNET_OPTION_REFRESH - No admin elevation needed (user-level registry key)
- VPN functions are all no-ops (Windows VPN clients don’t compete for proxy the same way)
Ghost supports GNOME and KDE desktop environments:
GNOME (also Unity, Cinnamon, Budgie): Uses gsettings to configure org.gnome.system.proxy — sets mode to manual, configures HTTP/HTTPS host+port, and sets ignore-hosts for localhost and private networks.
KDE: Uses kwriteconfig6 (fallback: kwriteconfig5) to write to kioslaverc, then sends a DBus signal to notify KIO of the change.
Desktop environment is detected via the XDG_CURRENT_DESKTOP environment variable.
VPN functions are all no-ops on Linux.
Known Tradeoffs
Section titled “Known Tradeoffs”| Tradeoff | Why | Mitigation |
|---|---|---|
| Password prompt when VPN is active | Writing to SCDynamicStore requires root privileges. macOS has no API for unprivileged State writes. | Only prompted once at startup (credentials cached for sudo -n reapply). |
| Session logout when toggling proxy | TLS certificate chain changes. Browser has cached TLS sessions with Ghost’s certs; switching to direct connection invalidates them. | Start Ghost before opening the browser. |
| Force-quit leaves stale State keys | Shutdown cleanup can’t run if the process is killed. | CleanupStaleStateKeys at next startup removes leftovers. |
| Interface-based detection, not connectivity | IsNetworkAvailable checks for a routable IP, not actual internet connectivity. A connected but captive-portal’d network would report as “available.” | 5-second polling self-corrects quickly once real connectivity is established. |
| 60-second osascript timeout | The admin dialog blocks the startup sequence while waiting for user input. | Only happens when a VPN is detected. No timeout during normal operation. |
Constants Reference
Section titled “Constants Reference”| Constant | Value | Purpose |
|---|---|---|
| Watchdog tick interval | 5 seconds | How often drift/VPN checks run |
| Network recovery delay | 2 seconds | Wait after offline→online before re-enabling proxy |
| Command timeout | 10 seconds | All networksetup/scutil commands |
| Override dialog timeout | 60 seconds | osascript admin password dialog (override + reapply) |
| Restore dialog timeout | 30 seconds | osascript admin password dialog (shutdown) |
| Proxy stop retries | 3 attempts, 500ms apart | sysproxy.Disable() in stop handler |
| Tunnel service keywords | 16 patterns | Services skipped during proxy configuration |