All posts
vpnnetworkingmacosdevopsproxydnsforticlientsysadmin

Turning Your Mac Into a VPN Proxy Gateway: Sharing a FortiClient Session Across Devices

Bypassing FortiClient's single-session VPN limit by turning a Mac into a proxy gateway — composing tinyproxy, dnsmasq, and a PAC file to share one VPN tunnel across multiple LAN devices without opening a second authenticated session.

FortiClient enforces a single concurrent session per user credential at the FortiGate server level. This is a well-known licensing constraint — each authenticated SSL VPN tunnel consumes a seat, and the server terminates the older session the moment a second one is negotiated under the same identity.

This post documents a complete workaround: routing a secondary device's traffic through an existing VPN tunnel on a Mac by composing three standard Unix tools — tinyproxy, dnsmasq, and a PAC file server. No kernel extensions, no VPN reconfiguration, no second credential. The approach draws on foundational networking concepts — split tunneling, split DNS, and HTTP CONNECT proxying — that are worth understanding in their own right.


How FortiClient Session Termination Works

FortiClient establishes an SSL VPN tunnel to a FortiGate appliance. The server authenticates the user, assigns a virtual IP to the tunnel interface (on macOS, typically a utun interface), and registers the session against the user's identity.

The relevant server-side constraint:

config vpn ssl settings
    set concurrent-session limit 1
end

When a second device presents the same credentials, the FortiGate detects the collision and tears down the older session. This is deterministic — the server is not malfunctioning, it is enforcing policy. The only path to two simultaneous sessions from the same identity is either a policy change on the FortiGate or routing a second device's traffic through the existing tunnel without opening a new session.

The latter is what this post covers.


Network Model

Baseline Topology

Phone  ──── Wi-Fi ──── Home Router ──── Internet
Laptop ──── Wi-Fi ──── Home Router ──── Internet ──── FortiGate ──── Corporate Network
                                                            │
                                                       utun interface
                                                    (VPN-assigned IP)

The phone has no reachable path to corporate resources. It cannot resolve internal hostnames — those are only answerable by DNS servers behind the VPN — and it cannot route to internal IP ranges, which are only reachable via the tunnel.

Target Topology

Phone ──── Wi-Fi ──── Laptop :8888 (tinyproxy) ──── utun ──── Corporate Network
Phone ──── Wi-Fi ──── Laptop :53   (dnsmasq)   ──── Corporate DNS / 8.8.8.8
Phone ──── Wi-Fi ──── Internet (DIRECT)         ──── Public Internet

The laptop becomes a selective gateway. Corporate traffic is proxied through it; public traffic goes direct. DNS is resolved in a split fashion depending on the queried namespace.


Why Each Naive Approach Fails

Several seemingly reasonable approaches fail here. Understanding the failure modes is more useful than just knowing what works.

macOS Internet Sharing

Internet Sharing on macOS creates a NAT layer and shares one network interface over another. The problem: to share over Wi-Fi, macOS creates a new Wi-Fi hotspot, which requires the phone to disconnect from the existing network and join the laptop's hotspot. This breaks existing connectivity, adds an unnecessary Wi-Fi relay hop, and most critically — a single Wi-Fi radio (en0) cannot simultaneously associate with one AP (your router) and act as a softAP for a second device. The two modes are mutually exclusive on the same adapter.

SOCKS5 Proxy (dante, microsocks)

SOCKS5 is a general-purpose TCP proxy protocol that should theoretically work. In practice, iOS's handling of SOCKS5 for HTTPS is unreliable. The core issue: HTTPS over SOCKS5 requires the SOCKS layer to negotiate a CONNECT-like tunnel before the TLS handshake begins. iOS's network stack does not implement this path consistently across all URL session configurations. dante, when bound to a utun interface, additionally exhibited TLS negotiation failures — the point-to-point nature of the VPN interface causes issues with how dante determines source address selection. microsocks worked for unencrypted HTTP but silently dropped HTTPS CONNECT attempts.

SSH Dynamic Forwarding (ssh -D)

ssh -D 1080 localhost creates a local SOCKS proxy that tunnels outbound connections through the SSH session. But the SSH session terminates at localhost — which means outbound traffic egresses from whichever interface the kernel selects for general traffic, typically en0, not utun. The VPN tunnel is not involved. Internal hostnames remain unresolvable, and internal IPs remain unreachable because the traffic never enters the tunnel.

iOS Private Wi-Fi Address (Hidden Failure Mode)

This is not an approach, but a silent killer: iOS randomizes its MAC address per Wi-Fi network by default (Settings → Wi-Fi → [network] → Private Wi-Fi Address). Combined with DHCP, this causes the phone's IP to change across reconnections. Any proxy allowlist keyed to the phone's IP breaks intermittently and non-obviously. The fix is to disable Private Wi-Fi Address on the local network, or configure the proxy to allow the entire LAN subnet rather than a specific host.


The Working Architecture

Component Overview

ComponentRoleBind AddressPort
tinyproxyHTTP/HTTPS CONNECT proxyen0 IP (Wi-Fi)8888
dnsmasqSplit DNS resolveren0 IP + 127.0.0.153
Python http.serverPAC file deliveryen0 IP8080

tinyproxy and HTTP CONNECT

tinyproxy is a single-process HTTP proxy that implements the CONNECT method. When iOS needs to open an HTTPS connection to host:443 through an HTTP proxy, it issues:

CONNECT host:443 HTTP/1.1

tinyproxy opens a raw TCP socket to host:443 and then splices the connection bidirectionally — the client's TLS handshake passes through unmodified. The proxy never sees the plaintext; it only sees the initial CONNECT request and then opaque encrypted bytes in both directions.

The critical property: tinyproxy binds to the laptop's en0 IP. The phone connects to the proxy over LAN. When tinyproxy opens its outbound TCP connection to the corporate host, the kernel consults the routing table. Corporate IP ranges have routes pointing to utun. So tinyproxy's outbound connection automatically enters the VPN tunnel — no special configuration required. The routing table does the work.

dnsmasq and Split DNS

FortiClient in split-tunnel mode injects routes for specific IP subnets into the kernel routing table, but it does not modify the system's DNS resolver configuration in a way that affects other devices. The phone, by default, queries the home router's DNS, which forwards to a public resolver. Corporate internal hostnames — which only exist in the corporate DNS zone — return NXDOMAIN from any public resolver.

The fix is to run dnsmasq as a DNS proxy on the laptop, and point the phone's DNS at the laptop's Wi-Fi IP. dnsmasq then applies split forwarding:

# Specific public-facing subdomains → public DNS
# (these are CDN-served and resolve to public IPs)
server=/www.corp.example/8.8.8.8
server=/portal.corp.example/8.8.8.8

# Corporate internal namespace → corporate DNS (reachable via utun)
server=/corp.example/<CORP_DNS_IP_1>
server=/corp.example/<CORP_DNS_IP_2>
server=/internal.example/<CORP_DNS_IP_1>

# All other queries → public DNS
server=8.8.8.8
server=8.8.4.4

dnsmasq applies the most specific matching rule first, so the public-facing subdomain exceptions take precedence over the wildcard domain rules. The corporate DNS servers are reachable because their IPs fall within the subnets routed through utun.

Why the public-facing subdomain exceptions matter: Some corporate domains serve both public content (via CDN) and internal APIs under the same parent zone. If www.corp.example is served via CloudFront, it resolves to public IPs from a public resolver but to a private internal IP from corporate DNS. Routing www queries to corporate DNS would return an unreachable internal IP. The exception routes those specific subdomains to 8.8.8.8, ensuring the phone gets the CDN-served public IP and can reach the site directly without going through the proxy.

PAC File (Proxy Auto-Configuration)

A PAC file is a JavaScript module with a single exported function — FindProxyForURL(url, host) — that the OS calls for every outbound connection. It returns either "DIRECT" or "PROXY host:port". The phone fetches it once (with periodic refresh) from a URL configured in Wi-Fi proxy settings.

function FindProxyForURL(url, host) {
    // Public-facing CDN subdomains bypass the proxy
    if (host === "www.corp.example" || host === "portal.corp.example") {
        return "DIRECT";
    }

    // High-bandwidth static asset paths bypass the proxy
    if (url.indexOf("/gis/") !== -1 || url.indexOf("/arcgis/") !== -1) {
        return "DIRECT";
    }

    // Internal corporate namespaces route through the proxy
    if (dnsDomainIs(host, ".corp.example") || dnsDomainIs(host, ".internal.example")) {
        return "PROXY <LAPTOP_WIFI_IP>:8888";
    }

    // Everything else is direct
    return "DIRECT";
}

This is split tunneling implemented at the application proxy layer — functionally equivalent to what FortiClient itself does at the IP routing layer, but expressed as per-hostname and per-path routing logic rather than CIDR-based route injection.

The GIS/ArcGIS path exception deserves explanation: GIS tile servers serve large datasets (imagery, map tiles) that have no need to traverse a VPN tunnel. Routing that traffic through the proxy would waste bandwidth on the VPN interface and add latency for no security or functional benefit.


Full Configuration

tinyproxy (/opt/homebrew/etc/tinyproxy/tinyproxy.conf)

Port 8888
Listen <LAPTOP_WIFI_IP>
Timeout 600
Allow 127.0.0.1
Allow <HOME_SUBNET>.0/24
MaxClients 100
MinSpareServers 5
MaxSpareServers 20
StartServers 10
LogLevel Info

Listen is bound to the Wi-Fi IP rather than 0.0.0.0 to scope the proxy to the LAN. Allow is set to the entire /24 subnet to avoid hardcoding the phone's IP (which may change due to DHCP or the Private Address feature).

dnsmasq (/opt/homebrew/etc/dnsmasq.conf)

listen-address=127.0.0.1,<LAPTOP_WIFI_IP>
bind-interfaces
no-resolv

server=/www.corp.example/8.8.8.8
server=/portal.corp.example/8.8.8.8

server=/corp.example/<CORP_DNS_IP_1>
server=/corp.example/<CORP_DNS_IP_2>
server=/internal.example/<CORP_DNS_IP_1>

server=8.8.8.8
server=8.8.4.4

no-resolv prevents dnsmasq from inheriting the system's /etc/resolv.conf — full control over forwarding is maintained explicitly.

PAC File Delivery

cd ~/vpn-proxy && python3 -m http.server 8080

Phone configuration: Wi-Fi → network → Configure Proxy → Automatichttp://<LAPTOP_WIFI_IP>:8080/proxy.pac


The DHCP Problem and the Startup Script

The laptop's en0 IP is DHCP-assigned. On router reboot, lease expiry, or network reconnect, the IP changes. This invalidates every binding: tinyproxy's Listen directive, dnsmasq's listen-address, and the proxy IP embedded in the PAC file. The setup silently breaks.

The solution is a startup script that auto-detects the current Wi-Fi IP at invocation time and rewrites all three configs before starting services.

vpn-proxy-start.sh

#!/bin/bash
set -euo pipefail

# Detect current Wi-Fi IP
WIFI_IP=$(ipconfig getifaddr en0 2>/dev/null || true)
if [[ -z "$WIFI_IP" ]]; then
    echo "ERROR: No IP on en0. Is Wi-Fi connected?"
    exit 1
fi
echo "==> Wi-Fi IP: $WIFI_IP"

# Detect VPN interface (utun bound to the VPN-assigned subnet)
VPN_IP=$(netstat -rn | awk '
    /^Destination/ { next }
    /utun/ && /172\./ { print $1; exit }
')
[[ -z "$VPN_IP" ]] && echo "WARNING: VPN interface not detected. Is FortiClient connected?"

# Rewrite configs
TINYPROXY_CONF="/opt/homebrew/etc/tinyproxy/tinyproxy.conf"
DNSMASQ_CONF="/opt/homebrew/etc/dnsmasq.conf"
PAC_FILE="$HOME/vpn-proxy/proxy.pac"

sudo sed -i '' "s/^Listen .*/Listen $WIFI_IP/" "$TINYPROXY_CONF"
sudo sed -i '' "s/^listen-address=.*/listen-address=127.0.0.1,$WIFI_IP/" "$DNSMASQ_CONF"
sed -i '' "s/[0-9]\+\.[0-9]\+\.[0-9]\+\.[0-9]\+:8888/$WIFI_IP:8888/g" "$PAC_FILE"
echo "==> Configs updated"

# Start services
sudo brew services start tinyproxy 2>/dev/null || sudo tinyproxy -c "$TINYPROXY_CONF"
sudo brew services start dnsmasq   2>/dev/null || sudo dnsmasq  -C "$DNSMASQ_CONF"
pkill -f "python3 -m http.server 8080" 2>/dev/null || true
(cd "$HOME/vpn-proxy" && python3 -m http.server 8080 &>/dev/null &)

# Verify
sleep 2
FAILED=()
pgrep -f tinyproxy          &>/dev/null || FAILED+=("tinyproxy")
pgrep -f dnsmasq            &>/dev/null || FAILED+=("dnsmasq")
pgrep -f "http.server 8080" &>/dev/null || FAILED+=("PAC server")

if [[ ${#FAILED[@]} -gt 0 ]]; then
    echo "ERROR: Failed to start: ${FAILED[*]}"
    exit 1
fi

echo ""
echo "✅  Proxy  : http://$WIFI_IP:8888"
echo "✅  DNS    : $WIFI_IP:53"
echo "✅  PAC    : http://$WIFI_IP:8080/proxy.pac"

The VPN interface detection uses awk scoped to lines containing both utun and an IP in the 172.x range — the subnet FortiClient assigns to the tunnel. If the VPN-assigned range differs in your environment, update the pattern.

sudo brew services is intentionally avoided for dnsmasq and tinyproxy because on Apple Silicon, brew services under sudo operates on the system Homebrew prefix (/usr/local) rather than the user prefix (/opt/homebrew), leading to silent failures where the service appears to start but is reading the wrong config file. The explicit -c / -C flags bypass this entirely.

vpn-proxy-stop.sh

#!/bin/bash

FAILED=()

stop_service() {
    local name="$1"
    local check_pattern="$2"
    local stop_cmd="$3"

    echo "==> Stopping $name..."
    if pgrep -f "$check_pattern" &>/dev/null; then
        eval "$stop_cmd"
        sleep 1
        pgrep -f "$check_pattern" &>/dev/null && FAILED+=("$name")
        echo "    $name: stopped"
    else
        echo "    $name: not running"
    fi
}

stop_service "tinyproxy" "tinyproxy" \
    "sudo brew services stop tinyproxy 2>/dev/null || sudo pkill -f tinyproxy"

stop_service "dnsmasq" "dnsmasq" \
    "sudo brew services stop dnsmasq 2>/dev/null || sudo pkill -f dnsmasq"

stop_service "PAC server" "http.server 8080" \
    "pkill -f 'python3 -m http.server 8080'"

echo ""
[[ ${#FAILED[@]} -gt 0 ]] \
    && echo "⚠️  Still running: ${FAILED[*]}" \
    || echo "✅  All services stopped"

vpn-proxy-setup.sh — First-Time Installation

#!/bin/bash
set -e

echo "==> Installing dependencies..."
brew install tinyproxy dnsmasq

echo "==> Scaffolding PAC directory..."
mkdir -p ~/vpn-proxy

cat > ~/vpn-proxy/proxy.pac << 'PACEOF'
function FindProxyForURL(url, host) {
    if (host === "www.corp.example" || host === "portal.corp.example") {
        return "DIRECT";
    }
    if (url.indexOf("/gis/") !== -1 || url.indexOf("/arcgis/") !== -1) {
        return "DIRECT";
    }
    if (dnsDomainIs(host, ".corp.example") || dnsDomainIs(host, ".internal.example")) {
        return "PROXY LAPTOP_IP_PLACEHOLDER:8888";
    }
    return "DIRECT";
}
PACEOF

echo "==> Done. Edit ~/vpn-proxy/proxy.pac with your domains, then run vpn-proxy-start.sh."

Shell Aliases

alias vpnproxy-start="bash ~/vpn-proxy/vpn-proxy-start.sh"
alias vpnproxy-stop="bash ~/vpn-proxy/vpn-proxy-stop.sh"
alias vpnproxy-status='
    for svc in tinyproxy dnsmasq "http.server 8080"; do
        echo -n "$svc: "
        pgrep -f "$svc" &>/dev/null && echo "running" || echo "stopped"
    done'

Debugging

Internal Hostnames Not Resolving on Phone

Verify dnsmasq is forwarding correctly from the laptop:

dig @<LAPTOP_WIFI_IP> internal.corp.example

Expected: an internal IP. If NXDOMAIN, the split DNS rules are misconfigured or the corporate DNS servers are unreachable (FortiClient may not be connected).

Verify the corporate DNS servers are reachable through the tunnel:

dig @<CORP_DNS_IP> internal.corp.example

If this also returns NXDOMAIN or times out, the DNS IP is wrong or the VPN route for that subnet isn't present.

PAC File Not Applied on Phone

curl http://<LAPTOP_WIFI_IP>:8080/proxy.pac

If this fails, the Python server is down. iOS also caches PAC files aggressively — toggling airplane mode forces a refresh.

HTTPS Connections Failing Through Proxy

Inspect the tinyproxy log:

tail -f /opt/homebrew/var/log/tinyproxy/tinyproxy.log

Look for CONNECT entries. If CONNECT lines are absent, the PAC file is not routing traffic through the proxy. If CONNECT lines are present but connections fail, the target IP may not have a route through utun — verify with netstat -rn | grep utun.

Random Connection Drops

Almost always the iOS Private Wi-Fi Address feature. The phone's IP changes between reconnections, and the new IP falls outside what the old session's connection state expects. Fix: disable Private Wi-Fi Address for the home network (Settings → Wi-Fi → [network] → Private Wi-Fi Address → Off).

VPN Interface Not Detected by Start Script

The detection awk pattern looks for utun lines with 172. in the routing table:

netstat -rn | grep utun

If the VPN-assigned subnet uses a different range (e.g., 10.x), update the awk pattern in vpn-proxy-start.sh accordingly.


Concept Summary

ConceptApplication
Split tunnelingVPN routes only specific subnets through utun; internet traffic goes via en0
HTTP CONNECTProxy method that creates a raw TCP tunnel — allows HTTPS to pass through an HTTP proxy without decryption
Split DNSNamespace-based DNS forwarding — internal names to corporate resolvers, public names to 8.8.8.8
PAC filePer-connection JavaScript routing function — implements application-layer split tunneling
utun interfacemacOS virtual network interface created by the VPN client; kernel routes VPN-destined traffic through it
DHCP volatilityLAN IP is not stable across router reboots; startup scripts must re-detect and rewrite all bindings

Conclusion

A VPN tunnel is a network interface. Any process on the host that opens outbound connections will have those connections routed according to the kernel's routing table — including through utun if the destination IP falls within a VPN-routed subnet. This means a standard HTTP proxy running on the laptop automatically inherits VPN routing for corporate destinations without any special configuration.

The non-trivial parts of this setup are DNS (corporate hostnames are invisible to public resolvers) and PAC file routing (ensuring only the right traffic goes through the proxy). Once those two are addressed with dnsmasq and a well-structured PAC file, the setup is robust and fully automatable.

The same pattern applies to any device on the LAN — a secondary laptop, a tablet, a development VM — that needs access to resources behind a VPN tunnel held by a different host.


bitsar.net/blog · GitHub