Zscaler DNS on Linux (systemd-resolved)
Why Zscaler Client Connector breaks DNS on Ubuntu with systemd-resolved after you quit the app, and a guard that ties Zscaler's DNS interference to whether the tray app is actually running.
Zscaler Client Connector on Linux manages DNS through its tunnel interface (zcctun0) and systemd-resolved. On some distros (notably newer Ubuntu) this integration is fragile: DNS silently breaks when the tunnel drops, and — worse — when you quit the app you can lose local/home DNS or internet entirely.
The one-sentence mental model
Zscaler funnels all DNS through zcctun0 (routing domain ~., server 100.64.0.2) and strips DNS from your physical NIC. The real trap: quitting the tray app does not stop the zsaservice backend, and the orphaned backend keeps forcing your NIC into tunnel-mode DNS with no tunnel behind it.
Symptoms
- Internal/private (ZPA) hostnames stop resolving even though the tunnel shows "connected".
- After disconnecting Private Access, DNS recovers — but after quitting the app it does not.
- After quitting the app, public sites still work (via fallback DNS) but your local/home zone (e.g. names served by a LAN resolver) stops resolving. Or you lose DNS entirely.
- Manually running
resolvectlfixes to your Wi‑Fi/Ethernet link don't stick — they get reverted within a couple of seconds.
Why it happens
systemd-resolved does split-DNS by routing domains. While the tunnel is up Zscaler sets:
Link N (zcctun0)
DNS Servers: 100.64.0.2
DNS Domain: ~. # "~." = send ALL queries to this linkThe ~. catch-all means every query is sent to 100.64.0.2. Zscaler also removes the DNS servers from the physical uplink and marks it Default Route: no, so nothing leaks around the tunnel.
The subtle failure is what happens on quit. The tray UI (ZSTray) exits, but the backend service does not:
zsaserviceruns withRestart=alwaysand isenabledat boot, so a tray-quit never stops it. A systemdExecStopPostteardown hook therefore never fires.- The orphaned
zsaservicekeeps enforcing tunnel-mode DNS on your physical NIC —DNS Domain: ~.,Default Route: no, no server — even thoughzcctun0is gone. - With a
~.link that has no server, every lookup falls through toFallbackDNS. Public names resolve; anything only your LAN resolver knows (home/internal zones) does not. - It re-applies that state every ~2 seconds, so a one-shot
resolvectlfix is undone almost immediately.
The tell-tale sign is your Wi‑Fi link showing DNS Domain: ~. and Default Route: no with no DNS Servers while the tunnel interface (zcctun0) doesn't exist. That's the orphaned backend, not a dead tunnel.
Diagnosis commands
# What each link is configured to do — look for "~." + "Default Route: no" + no servers on your NIC
resolvectl status
resolvectl status <wifi-iface>
# Is the tunnel resolver actually answering? (ground truth for "tunnel up")
dig @100.64.0.2 +time=2 +tries=1 +short gateway.zscaler.net
# Is the tray app actually running, vs. the orphaned backend?
pgrep -f '/opt/zscaler/bin/ZSTray' # empty = app quit
systemctl is-active zsaservice # often still "active" after a quit
# What DNS does NetworkManager know for this network? (the correct resolver to restore)
nmcli -g IP4.DNS dev show <wifi-iface>Fix: tie Zscaler's DNS interference to the app
Fighting the backend with periodic resolvectl revert loses — it re-applies faster than you can reconcile, and it also drops Default Route, which reverting alone doesn't restore. The reliable model is:
Zscaler should only touch DNS while the app is actually open. When the app is quit, stop the backend and restore the network's normal resolver.
A small daemon enforces exactly that.
Guard script
Save as /opt/zscaler/scripts/zscaler-dns-guard.sh and chmod +x. Adjust ZSCALER_DNS only if your build uses a different tunnel resolver (100.64.0.2 is standard); the ZSTray path matches a standard install.
#!/bin/bash
# Self-healing DNS guard for Zscaler + systemd-resolved.
#
# Model: Zscaler's DNS interference should exist ONLY while the tray app is open.
# * App OPEN (ZSTray running) -> ensure zsaservice is running; if the tunnel
# is up, make sure zcctun0 has DNS + "~.".
# * App QUIT (ZSTray gone) -> stop zsaservice so it can't hijack DNS, then
# restore the uplink to its normal (DHCP /
# NetworkManager) resolver + default route.
set -uo pipefail
TUN="zcctun0"
ZSCALER_DNS="100.64.0.2"
PROBE="gateway.zscaler.net"
INTERVAL=2
app_running() {
pgrep -f '/opt/zscaler/bin/ZSTray' &>/dev/null
}
tunnel_up() {
ip link show "$TUN" &>/dev/null || return 1
timeout 2 dig @"$ZSCALER_DNS" +time=1 +tries=1 +short "$PROBE" &>/dev/null
}
tun_dns_applied() {
resolvectl status "$TUN" 2>/dev/null | grep -q "DNS Servers: $ZSCALER_DNS"
}
apply_tun() {
resolvectl dns "$TUN" "$ZSCALER_DNS"
resolvectl domain "$TUN" '~.'
resolvectl flush-caches
}
default_iface() {
ip route show default 2>/dev/null | awk -v tun="$TUN" '$5!=tun {print $5; exit}'
}
# Healthy = real DNS servers, no "~." catch-all, default route enabled.
iface_healthy() {
local st
st="$(resolvectl status "$1" 2>/dev/null)"
echo "$st" | grep -q "DNS Servers:" || return 1
echo "$st" | grep -q "DNS Domain: ~\." && return 1
echo "$st" | grep -q "Default Route: no" && return 1
return 0
}
# Restore the uplink to the resolver NetworkManager knows (DHCP-provided, so it
# also covers local/home zones), re-enable it as a default DNS route, and drop
# any "~." leftover. Prefer NM's DNS over the gateway — the gateway may not serve
# your local zones.
restore_physical_dns() {
local def_if nm_dns
def_if="$(default_iface)"
[ -n "${def_if:-}" ] || return 0
iface_healthy "$def_if" && return 0
nm_dns="$(nmcli -g IP4.DNS dev show "$def_if" 2>/dev/null | tr '\n|' ' ')"
resolvectl revert "$def_if" 2>/dev/null || true
resolvectl default-route "$def_if" yes 2>/dev/null || true
[ -n "${nm_dns// /}" ] && resolvectl dns "$def_if" $nm_dns
resolvectl flush-caches
}
# Debounce: only treat the app as "quit" after it's been gone for several
# consecutive checks, so a brief ZSTray restart/relaunch doesn't churn zsaservice.
MISS_LIMIT=3
misses=0
while true; do
if app_running; then
misses=0
systemctl is-active --quiet zsaservice || systemctl start --no-block zsaservice
if tunnel_up; then
tun_dns_applied || apply_tun
fi
else
misses=$((misses + 1))
if [ "$misses" -ge "$MISS_LIMIT" ]; then
systemctl is-active --quiet zsaservice && systemctl stop zsaservice
ip link show "$TUN" &>/dev/null && resolvectl revert "$TUN" 2>/dev/null || true
restore_physical_dns
fi
fi
sleep "$INTERVAL"
donesystemd service
/etc/systemd/system/zscaler-dns-guard.service:
[Unit]
Description=Zscaler DNS self-healing guard
After=network.target zsaservice.service
Wants=network.target
[Service]
Type=simple
ExecStart=/opt/zscaler/scripts/zscaler-dns-guard.sh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetEnable it:
sudo systemctl daemon-reload
sudo systemctl enable --now zscaler-dns-guard.serviceRemove Zscaler's blocking start hook (if present)
Some setups add a drop-in that re-applies tunnel DNS via ExecStartPost and waits (up to ~60s) for zcctun0 to appear. That makes systemctl start zsaservice block and time out, which fights the guard. The guard applies tunnel DNS itself, so remove it:
sudo rm -f /etc/systemd/system/zsaservice.service.d/resolve-dns.conf
sudo rmdir /etc/systemd/system/zsaservice.service.d 2>/dev/null || true
sudo systemctl daemon-reloadFallback DNS safety net
Give systemd-resolved a public fallback for any window where no per-link resolver is configured. It's only used when no other resolver is set, so it never interferes with normal operation.
/etc/systemd/resolved.conf:
[Resolve]
FallbackDNS=1.1.1.1 9.9.9.9
DNSSEC=nosudo systemctl restart systemd-resolvedHow the guard behaves
| App / tunnel state | Guard action |
|---|---|
App open, tunnel up (100.64.0.2 answers) | Ensure zsaservice is running; ensure zcctun0 has DNS 100.64.0.2 + ~. → internal apps resolve |
| App open, tunnel down | Ensure zsaservice is running; leave DNS to Zscaler (disconnect handling already works) |
| App quit (debounced ~6s) | Stop zsaservice, revert zcctun0, restore the uplink's DHCP DNS + Default Route: yes → public and local/home zones resolve |
Because it keys off the tray app rather than fighting the backend, DNS state matches your intent: Zscaler on when the app is open, normal networking when it's quit.
Stopping zsaservice on quit means Zscaler is genuinely off until you reopen the app. When you launch the tray app again, the guard starts zsaservice for you within a couple of seconds.
Verify
# App open, tunnel up: internal + public resolve
resolvectl query vault.internal.example.com
resolvectl query google.com
# Quit the Zscaler tray app, wait ~10s, then confirm normal DNS is back:
resolvectl query google.com # public
resolvectl query host.your-local-zone.lan # local/home zone via your LAN resolver
resolvectl status <wifi-iface> # should show real DNS Servers, Default Route: yes, no "~."Notes
- The
DNSSEC=noline matches Zscaler's own installer setting; keep it. - While the tunnel is up,
~.means all DNS (public included) goes through Zscaler — that's normal Client Connector behaviour, not a side effect of this guard. - Prefer the NetworkManager-known DNS (
nmcli -g IP4.DNS dev show <iface>) when restoring, not the default gateway — the gateway may not serve your local/home zones, whereas the DHCP-advertised resolver does. - If you re-run any tooling that regenerates
/etc/systemd/resolved.conf, re-addFallbackDNS.