wiki
Miscellaneous

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 resolvectl fixes 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 link

The ~. 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:

  • zsaservice runs with Restart=always and is enabled at boot, so a tray-quit never stops it. A systemd ExecStopPost teardown hook therefore never fires.
  • The orphaned zsaservice keeps enforcing tunnel-mode DNS on your physical NIC — DNS Domain: ~., Default Route: no, no server — even though zcctun0 is gone.
  • With a ~. link that has no server, every lookup falls through to FallbackDNS. 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 resolvectl fix 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"
done

systemd 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.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now zscaler-dns-guard.service

Remove 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-reload

Fallback 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=no
sudo systemctl restart systemd-resolved

How the guard behaves

App / tunnel stateGuard 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 downEnsure 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=no line 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-add FallbackDNS.

On this page