Skip to content

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.

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.

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.

Ghost uses five complementary mechanisms to maintain proxy control across all scenarios.

This is the standard approach — what every proxy tool uses. Ghost’s EnableWithPAC() configures every non-tunnel network service:

Enable commands (per service):

#CommandPurpose
1networksetup -setwebproxy <service> 127.0.0.1 4545Set HTTP proxy address
2networksetup -setsecurewebproxy <service> 127.0.0.1 4545Set HTTPS proxy address
3networksetup -setwebproxystate <service> onEnable HTTP proxy
4networksetup -setsecurewebproxystate <service> onEnable HTTPS proxy
5networksetup -setautoproxyurl <service> http://127.0.0.1:5565/proxy.pacSet PAC URL for automatic proxy configuration
6networksetup -setproxyautodiscovery <service> offDisable 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:

#CommandWhy This Order Matters
1networksetup -setwebproxy <service> "" "0"Clear HTTP proxy address first
2networksetup -setsecurewebproxy <service> "" "0"Clear HTTPS proxy address
3networksetup -setwebproxystate <service> offTurn off HTTP proxy state AFTER clearing address
4networksetup -setsecurewebproxystate <service> offTurn off HTTPS proxy state AFTER clearing address
5networksetup -setautoproxyurl <service> ""Remove PAC URL
6networksetup -setautoproxystate <service> offDisable auto proxy
7networksetup -setproxyautodiscovery <service> onRe-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).

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):

  1. Check if scutil --proxy already shows Ghost’s settings (HTTPEnable: 1, HTTPProxy: 127.0.0.1, HTTPPort: 4545). If yes, nothing to do.
  2. List all State proxy keys: echo "list State:/Network/Service/.*/Proxies" | scutil
  3. For each key, read its contents and check for problems:
    • Ghost’s address with proxy disabled (HTTPPort: 4545 but HTTPEnable: 0)
    • WPAD enabled (ProxyAutoDiscoveryEnable: 1) — VPN wants automatic discovery
  4. If a problematic key is found, return it for overriding
  5. 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.init
d.add HTTPEnable # 1
d.add HTTPPort # 4545
d.add HTTPProxy 127.0.0.1
d.add HTTPSEnable # 1
d.add HTTPSPort # 4545
d.add HTTPSProxy 127.0.0.1
d.add ProxyAutoConfigEnable # 1
d.add ProxyAutoConfigURLString http://127.0.0.1:5565/proxy.pac
d.add ProxyAutoDiscoveryEnable # 0
set State:/Network/Service/<vpn-service-id>/Proxies

This 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.

CleanupStaleStateKeys runs at startup before enabling the proxy:

  1. Lists all State proxy keys via scutil
  2. Reads each key’s contents
  3. 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)
  4. 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.

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:

  1. Enumerate all network interfaces via net.Interfaces()
  2. Skip interfaces that are not UP or are loopback
  3. For each remaining interface, check its assigned addresses
  4. Look for any IPv4 address that isn’t link-local (169.254.x.x)
  5. Return true if found — the machine has at least one routable IP

Edge detection:

The watchdog detects transitions, not just states:

TransitionAction
Online → OfflineDisable system proxy immediately. Set wasNetworkDown flag. Skip all drift checks while down.
Offline → OnlineCall 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:

  1. HTTP/1.1 idle connections (h1.CloseIdleConnections())
  2. HTTP/2 idle connections (h2.CloseIdleConnections())
  3. 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).

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.

When Ghost starts in system_proxy mode:

  1. Start proxy server on :4545 and API server on :5565
  2. CleanupCleanupStaleStateKeys("127.0.0.1", port) — remove leftover State keys from crashes
  3. EnableEnableWithPAC("127.0.0.1", port, apiPort) — configure networksetup on all non-tunnel services
  4. DetectDetectVPNStateOverride("127.0.0.1", port) — check if scutil --proxy reflects Ghost
  5. Override — if a VPN State key was detected, OverrideVPNState() — native password dialog
  6. Watchdog — started when the proxy is activated via the API (POST /api/v1/proxy/start), runs every 5 seconds

When Ghost is stopped (SIGINT, SIGTERM, or via UI):

  1. RestoreRestoreVPNState() — remove State key override (sudo -n, fallback osascript)
  2. Disablesysproxy.Disable() — clear proxy on all services (address-first ordering)
  3. Stop proxy — drain active connections (10-second timeout)
  4. Flush — drain buffered security findings and tag updates
  5. Stop API — shut down the HTTP API server

When the proxy is stopped via the API (POST /api/v1/proxy/stop):

  1. Stop watchdog — cancel the 5-second ticker goroutine
  2. RestoreRestoreVPNState() — remove State key override
  3. Disablesysproxy.Disable() with 3 retries, 500ms between — handles transient networksetup failures during interface transitions
  4. Stop proxy — shut down the proxy server

Each tick of the watchdog goroutine:

  1. Guard — if the proxy isn’t running, exit the goroutine
  2. Check proxy mode — read config to see if system_proxy mode is active
  3. Network checkIsNetworkAvailable() → detect down/up edge transitions
  4. If offline — disable system proxy, set wasNetworkDown, skip remaining checks
  5. If online after being offline — flush connections, wait 2s, re-enable proxy
  6. Drift detection (only in system_proxy mode) — AllServicesHealthy() checks every non-tunnel service has the correct proxy address. If any service has drifted, re-run EnableWithPAC().
  7. Effective proxy check (only in system_proxy mode) — IsEffectiveProxySet() runs scutil --proxy to check the resolved proxy. If Ghost’s settings aren’t there:
    • If Ghost already has a VPN override key (from startup), call ReapplyVPNOverride() (silent sudo -n first)
    • If no existing override (VPN connected mid-session), run full detection + override cycle

VPN coexistence is a macOS-specific problem. On other platforms:

Ghost sets the proxy via the Windows registry:

  • Key: HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings
  • Values: ProxyEnable (DWORD 1/0), ProxyServer (string host:port), ProxyOverride (bypass list: localhost;127.0.0.1;::1;<local>)
  • After changing: notifies applications via InternetSetOptionW with INTERNET_OPTION_SETTINGS_CHANGED and INTERNET_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.

TradeoffWhyMitigation
Password prompt when VPN is activeWriting 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 proxyTLS 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 keysShutdown cleanup can’t run if the process is killed.CleanupStaleStateKeys at next startup removes leftovers.
Interface-based detection, not connectivityIsNetworkAvailable 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 timeoutThe admin dialog blocks the startup sequence while waiting for user input.Only happens when a VPN is detected. No timeout during normal operation.
ConstantValuePurpose
Watchdog tick interval5 secondsHow often drift/VPN checks run
Network recovery delay2 secondsWait after offline→online before re-enabling proxy
Command timeout10 secondsAll networksetup/scutil commands
Override dialog timeout60 secondsosascript admin password dialog (override + reapply)
Restore dialog timeout30 secondsosascript admin password dialog (shutdown)
Proxy stop retries3 attempts, 500ms apartsysproxy.Disable() in stop handler
Tunnel service keywords16 patternsServices skipped during proxy configuration