Clientless-access gateway

The clientless-access gateway lets people reach internal web resources from a browser that has only the Netzilo browser extension: no Netzilo client and no admin rights on the device. On a self-hosted server it is the gateway container, an HTTPS proxy on port 8443. This page covers the server side: checking it, opening its port, adding it to an older server, running it on another host, and its options.

How users get access, the profile's Proxy tab and what users see are on Clientless Access. On Netzilo Cloud the gateway is provided and there is nothing to install.

How it works

  1. A user signs in with Login in the extension's popup.
  2. The extension asks the management server how this browser should route, and installs a proxy configuration that sends only internal names and addresses to the gateway.
  3. The extension answers the gateway's login request with the user's sign-in token. The user sees no prompt.
  4. The gateway checks the token with management and starts a session for that user on that browser: a virtual peer named vp-<n>-PROXY in the user's account, with that user's groups and policies. Requests go through that session's tunnel, so the user reaches exactly what their policies allow.
  5. After 60 minutes without traffic the session stops. The next request starts it again, which takes 10 to 20 seconds.

The gateway runs in shared mode: it has no peer of its own, and it talks to management inside the compose stack. It serves the server's own certificate: Caddy's Let's Encrypt certificate, read from the netzilo_caddy_data volume, or the files in certs/ when you use your own certificate. Renewals are picked up within 30 seconds, without a restart.

The gateway is not the same as published applications, which serve browsers with nothing installed through the reverse-proxy container on port 443.

Before you start

  • A release with clientless access. From any machine:

    curl -sS -o /dev/null -w '%{http_code}\n' https://go.example.com/api/users/current/routing
    

    401 means management supports clientless access. 404 means it is older: update management, then the dashboard, see Upgrade and add components.

  • A certificate that browsers trust. A public certificate such as Let's Encrypt, or one from a company CA your browsers trust. A self-signed certificate does not work for browsers.

  • The Netzilo browser extension 5.0.455 or later on the users' browsers. Safari is not supported.

Step 1: Check the gateway

A server installed with a current installer, or a current AWS or Azure image, already runs the gateway. Check it in the compose folder (/opt/netzilo, or /opt/netzilo/run on a marketplace image):

cd /opt/netzilo
sudo grep -c '^  gateway:' docker-compose.yml
sudo docker compose logs --tail=50 gateway | grep 'gateway:'

0 means the server has no gateway yet: see Add the gateway to an older server. Otherwise the log shows:

gateway: version <v> starting in shared mode (management http://management:80, egress public)
gateway: TLS certificate loaded from ...
gateway: proxy listening on [::]:8443 (https)

From any machine, the gateway asks for a login, which proves it answers:

curl -sS -o /dev/null -w 'gateway %{http_code}\n' --proxy "https://go.example.com:8443" http://example.com/

The answer is 407.

Step 2: Open port 8443

Allow inbound TCP 8443 to the server from your users' networks. Docker publishes the port itself, so a host firewall such as ufw does not filter it: the cloud security group or network firewall is what counts.

Current AWS and Azure templates open 8443. Deployments created from older templates do not. Add the rule:

# AWS: the stack's security group
aws ec2 authorize-security-group-ingress --group-id <sg-id> --protocol tcp --port 8443 --cidr 0.0.0.0/0
# Azure: the deployment's network security group (pick a free priority)
az network nsg rule create -g <resource-group> --nsg-name <nsg> -n Allow-Gateway-8443 \
  --priority 1010 --direction Inbound --access Allow --protocol Tcp --destination-port-ranges 8443

Limit the source to your users' networks when you know them.

Step 3: Switch on clientless access

In the dashboard, go to Settings > Permissions and turn on Allow clientless access.

The Permissions tab of Settings: Allow regular users to use the AI Assistant, Allow clientless access with the Gateway proxy address netzilo.example.com:8443, and Allow published applications with the application domain apps.example.com and the address 203.0.113.10

FieldWhat to enter
Allow clientless accessOn. Off means no browser gets automatic routing; browsers remove it within 5 minutes
Gateway proxy addressLeave empty when the gateway runs on the server itself: browsers then use the server's domain on port 8443. Set host:port only when the gateway runs on another host or port. It must match the gateway's certificate

Then give users routing through a profile with the browser extension, as described in Clientless Access.

Add the gateway to an older server

Servers installed before the gateway existed run without it. Never re-run the installer to add it: that erases the server.

Servers installed with the one-line installer. If the published installer has the component commands (check as described in Upgrade and add components), one command adds the gateway, with a backup first:

curl -fsSL https://pkg.netzilo.com/download/install-netzilo.sh | sudo bash -s -- install gateway

It refuses a plain-HTTP server. Then run the checks in Gates, open port 8443 and switch clientless access on.

AWS and Azure images, or an installer without the component commands. Add the gateway in place with the procedure below. First take a backup (see Backup and restore) and check that the server has a certificate:

sudo grep -E '^(NETBIRD_DOMAIN|NETBIRD_HTTP_PROTOCOL|TLS_MODE)=' /opt/netzilo/netzilo-state.env

On a Let's Encrypt server the procedure recreates the caddy container once, so the dashboard and API are unreachable for a few seconds. Nothing else restarts.

What it does: finds the compose folder and the domain; on a Let's Encrypt server, copies Caddy's current certificate out of its container into a new named volume netzilo_caddy_data, so that no new certificate is requested; on a server with your own certificate, mounts the same certs/ folder; adds the gateway service and its volumes to docker-compose.yml, and validates the file. It refuses to run twice and keeps a timestamped copy of the compose file.

if [ -f /opt/netzilo/run/docker-compose.yml ]; then C=/opt/netzilo/run; else C=/opt/netzilo; fi
cd "$C"
sudo grep -q '^  gateway:' docker-compose.yml && echo "gateway is already present; stop here"
DOMAIN=$(sudo sed -n 's/^NETBIRD_DOMAIN=//p' /opt/netzilo/netzilo-state.env | tr -d '"')
echo "domain: $DOMAIN"
stamp=$(date +%s)
sudo cp -a docker-compose.yml "docker-compose.yml.bak-$stamp"
# Let's Encrypt servers: keep Caddy's current certificate before its container changes.
sudo docker cp caddy:/data "caddy-data-seed-$stamp"
sudo DOMAIN="$DOMAIN" python3 - <<'EOF'
import os, re
dom = os.environ["DOMAIN"]
if not dom: raise SystemExit("no NETBIRD_DOMAIN in /opt/netzilo/netzilo-state.env")
p = "docker-compose.yml"; text = open(p).read()
if re.search(r"^  gateway:", text, re.M): raise SystemExit("gateway already present")
def block(name):
    m = re.search(r"^  %s:\n(?:(?!^  \S).*\n)*" % name, text, re.M)
    if not m: raise SystemExit("no %s service in docker-compose.yml" % name)
    return m
caddy, mgmt = block("caddy"), block("management")
provided = re.search(r"^      - (\S+):/data/caddy/certificates/?\s*$", caddy.group(0), re.M)
ssl = re.search(r"^      - (\S+):/etc/ssl/certs:ro\s*$", caddy.group(0), re.M)
extra = re.search(r"^    extra_hosts:\n(?:^      .*\n)+", mgmt.group(0), re.M)
vols = ["  netzilo_gateway:\n"]
if provided:
    cert_mount = "      - %s:/certs:ro\n" % provided.group(1)
    tls = '"--tls-cert", "/certs/fullchain.pem", "--tls-key", "/certs/privkey.pem",'
else:
    if not re.search(r"^      - netzilo_caddy_data:/data\s*$", caddy.group(0), re.M):
        cv = re.search(r"^    volumes:\n", caddy.group(0), re.M)
        if not cv: raise SystemExit("caddy has no volumes: list")
        at = caddy.start() + cv.end()
        text = text[:at] + "      - netzilo_caddy_data:/data\n" + text[at:]
        vols.append("  netzilo_caddy_data:\n")
        open(".gateway-caddy-changed", "w").write("1\n")
    cert_mount = "      - netzilo_caddy_data:/caddy:ro\n"
    tls = '"--tls-cert-dir", "/caddy/caddy/certificates", "--tls-domain", "%s",' % dom
svc = (
    "  # Clientless access gateway (browser extension -> HTTPS proxy on :8443), added\n"
    "  # after install. Shared mode: each browser session is a guest peer of its user.\n"
    "  gateway:\n"
    "    image: ghcr.io/netzilo/net-gateway:latest\n"
    "    container_name: gateway\n"
    "    restart: unless-stopped\n"
    "    user: '0:0'\n"
    "    cap_drop:\n"
    "      - ALL\n"
    "    security_opt:\n"
    "      - 'no-new-privileges:true'\n"
    "    networks:\n"
    "      - netzilo\n"
    + (extra.group(0) if extra else "")
    + "    depends_on:\n"
    "      - management\n"
    "    ports:\n"
    "      - '8443:8443'\n"
    "    volumes:\n"
    + ("      - %s:/etc/ssl/certs:ro\n" % ssl.group(1) if ssl else "")
    + "      - netzilo_gateway:/var/lib/netzilo-gateway\n"
    + cert_mount
    + "    command: [\n"
    '      "run",\n'
    '      "--management-url", "http://management:80",\n'
    '      "--listen", ":8443",\n'
    "      " + tls + "\n"
    '      "--admin-listen", ":9090",\n'
    '      "--idle-timeout", "60m",\n'
    '      "--data-dir", "/var/lib/netzilo-gateway",\n'
    '      "--log-file", "console",\n'
    '      "--log-level", "info"\n'
    "    ]\n"
)
top = re.search(r"^volumes:\n", text, re.M)
if not top: raise SystemExit("no top-level volumes: section in docker-compose.yml")
text = text[:top.start()] + svc + "volumes:\n" + "".join(vols) + text[top.end():]
open(p, "w").write(text)
EOF
sudo docker compose config -q && echo "compose file valid"

Then start it, in the same shell (it reuses $C and $stamp):

cd "$C"
sudo docker compose pull gateway
if [ -f .gateway-caddy-changed ]; then
  sudo docker compose up --no-start --no-deps gateway              # creates netzilo_caddy_data, touches nothing running
  VOL=$(sudo docker volume ls -q --filter label=com.docker.compose.volume=netzilo_caddy_data)
  IMG=$(sudo docker inspect caddy --format '{{.Config.Image}}')
  sudo docker run --rm --entrypoint sh -v "$VOL":/to -v "$PWD/caddy-data-seed-$stamp":/from:ro "$IMG" -c 'cp -a /from/. /to/'
  sudo docker compose up -d --no-deps caddy gateway               # caddy: a few seconds of downtime
  sudo rm -f .gateway-caddy-changed
else
  sudo docker compose up -d --no-deps gateway
fi
sudo docker compose logs --since 2m gateway | grep 'gateway:'

caddy-data-seed-<stamp> holds a private key: delete it once the gateway serves the certificate (sudo rm -rf "$C"/caddy-data-seed-*).

Gates

CheckExpected
sudo docker compose psEvery service Up, including gateway
sudo docker compose logs gatewayTLS certificate loaded from ... and proxy listening on [::]:8443 (https), and no no TLS certificate yet after the first minute
curl -sS -o /dev/null -w '%{http_code}\n' https://go.example.com/200: the dashboard is back after Caddy's restart
openssl s_client -connect go.example.com:8443 -servername go.example.comThe server's certificate, with the same issuer as on port 443
Test the path without a browser with a real user's token200 for an internal address

Roll back

A gateway added with install gateway rolls back from the backup folder the command printed, see Upgrade and add components. For the in-place procedure, switch clientless access off first, then:

cd "$C"
sudo docker compose stop gateway && sudo docker compose rm -f gateway
sudo cp -a "docker-compose.yml.bak-<stamp>" docker-compose.yml
sudo docker compose up -d --no-deps caddy

The last line is needed only if Caddy's volume was added. The netzilo_caddy_data volume can stay; it only holds Caddy's certificates.

Run the gateway on another host

The gateway does not have to run next to management. Run it on another Linux host with Docker (linux/amd64) when it must sit in a DMZ, in another region or close to the resources, or when you use Netzilo Cloud and want your own gateway.

The host needs:

  • A DNS name for the gateway, for example gw.example.com, and a certificate for it that browsers trust.
  • Inbound 8443/tcp from the browsers.
  • Outbound access like a Netzilo client: 443/tcp to the management server, 3478/udp and the relay ports to the relay, and UDP to peers for direct connections.

Bind the gateway to your account with the access token of an Admin Agent (create it under Team > Agents, see Agents). Keep the token in a file only root can read, not on the command line:

sudo install -d -m 700 /etc/netzilo-gateway
sudo sh -c 'umask 077; printf "NZ_GATEWAY_SERVICE_PAT=%s\n" "nzl_..." > /etc/netzilo-gateway/env'
sudo docker volume create netzilo_gateway
sudo docker run -d --name netzilo-gateway --restart unless-stopped \
  --user 0:0 --cap-drop ALL --security-opt no-new-privileges \
  -p 8443:8443 -p 127.0.0.1:9090:9090 \
  --env-file /etc/netzilo-gateway/env \
  -v /etc/letsencrypt:/etc/letsencrypt:ro \
  -v netzilo_gateway:/var/lib/netzilo-gateway \
  ghcr.io/netzilo/net-gateway:latest \
  run --management-url https://go.example.com \
      --listen :8443 \
      --tls-cert /etc/letsencrypt/live/gw.example.com/fullchain.pem \
      --tls-key /etc/letsencrypt/live/gw.example.com/privkey.pem \
      --admin-listen :9090 --idle-timeout 60m \
      --data-dir /var/lib/netzilo-gateway --log-file console --log-level info
sudo docker logs --tail=30 netzilo-gateway | grep 'gateway:'

The log shows gateway: bound to an account with <n> users. With certbot, mount the whole /etc/letsencrypt folder, because the files in live/ link into archive/. Use https://srv.netzilo.com as the management URL with Netzilo Cloud, or your server's domain when self-hosted.

Then set Settings > Permissions > Gateway proxy address to gw.example.com:8443. One account uses one gateway address; to spread users over several gateways, use custom PAC profiles per group.

Browser addresses behind your server's Caddy. A gateway on another host reaches a self-hosted management server through Caddy, which does not trust forwarded addresses from unknown hosts. Sessions then show the gateway's IP and location instead of the browser's, and location posture checks judge the gateway. Trust the gateway's public IP in the global options of the server's Caddyfile, in the existing servers block, then run sudo docker compose restart caddy (a few seconds of dashboard downtime):

{
  servers :80,:443 {
    protocols h1 h2c
    trusted_proxies static 203.0.113.20/32
  }
}

Trust only the gateway's own address: any host listed there may state a browser's address.

Options

The gateway's settings are command-line options in the command: of the gateway service in docker-compose.yml. After a change, recreate it with sudo docker compose up -d --no-deps gateway. Recreating the gateway drops browser sessions; browsers start new ones on their next request.

OptionDefaultControls
--idle-timeout60mHow long a session may go without traffic before it stops
--max-devices-per-user5Browser sessions per user at a time
--max-sessions2000Sessions in total
--revalidate10mHow often each session's credential is checked again. Blocking a user or revoking their token takes effect within this time
--egresspublicWhether traffic the user's tunnel does not cover may go to public addresses from the gateway host. off allows only the tunnel
--egress-allownonePrivate address ranges the gateway host may reach for such traffic
--service-patnoneBinds the gateway to one account (also NZ_GATEWAY_SERVICE_PAT)
--log-levelinfodebug logs every connection and its outcome. Set it back afterwards

The gateway never lets a browser reach the gateway host itself or a link-local address, and after 20 failed logins from one address within 5 minutes it blocks that address for 5 minutes.

Logs and health

cd /opt/netzilo
sudo docker compose logs --tail=200 gateway
sudo docker compose logs -f --since 10m gateway | grep 'gateway:'
Log lineMeaning
gateway: session vp-<n>-PROXY (<email>) ready: ...A browser session is up
gateway: session vp-<n>-PROXY (<email>) stopped: idleThe session ended; the next request starts it again
gateway: credential refused for "<user>" from <ip>: ...A wrong, expired or foreign token
gateway: <email> already has 5 browser sessions; a new device from <ip> refusedThe per-user limit
gateway: no TLS certificate yet (...)Caddy has no certificate for the domain yet, or the certificate mount is wrong
gateway: management: management not reachable yet, retrying ...Management is down or starting. After 5 minutes the gateway exits and Docker restarts it
Posture check '<name>' FAILED at <check>A policy's posture check fails for that browser

Logs never contain a credential. Browser sessions appear in Endpoint > Peers as vp-<n>-PROXY and are removed automatically some time after they go offline.

Troubleshooting

Test the path without a browser

From any machine that can reach the gateway, with a real user's access token (the user name is free text):

export TOKEN=nzl_...
curl -sS -o /dev/null -w 'internal -> HTTP %{http_code} in %{time_total}s\n' \
  --proxy "https://go.example.com:8443" --proxy-user "test:$TOKEN" http://intranet.example.internal/
curl -sS -o /dev/null -w 'wrong token -> HTTP %{http_code}\n' \
  --proxy "https://go.example.com:8443" --proxy-user "test:nzl_wrong" http://intranet.example.internal/
unset TOKEN

Expect 200 (the first call after an idle hour takes 10 to 20 seconds), then 407.

The gateway container keeps restarting. Read sudo docker compose logs --tail=50 gateway. management not reachable yet for 5 minutes means management is down: fix it first. management URL ...: want http(s)://host[:port] means the --management-url option is malformed.

The log says no TLS certificate yet. With Let's Encrypt, Caddy has not obtained the certificate for exactly the gateway's domain yet, or the domain differs from the Caddyfile's. With your own certificate, the mount or the file names (fullchain.pem, privkey.pem) are wrong.

Browsers show ERR_PROXY_CONNECTION_FAILED or ERR_TUNNEL_CONNECTION_FAILED. Port 8443 is blocked, the Gateway proxy address is wrong, or the browser does not trust the certificate or it is for another name. Test from the user's network with openssl s_client -connect go.example.com:8443.

407 again and again with a correct token. The token belongs to another server, has expired, or the user is not in the account the gateway is bound to.

429 too many devices for this user. The user has 5 sessions. Idle sessions end after 60 minutes, or raise --max-devices-per-user.

503 with Retry-After. Management is unreachable, the session limit is reached, or the session's tunnel was not ready within 30 seconds. The browser retries.

Sessions show the gateway's IP in Peers. A gateway on another host lacks trusted_proxies in the server's Caddyfile, see Run the gateway on another host.

For problems in the browser, such as the popup showing unavailable, see Clientless Access.

Limits

  • Safari is not supported. Firefox needs the extension allowed in private windows.
  • Browser sessions never pass process checks or Netzilo endpoint checks.
  • Only web traffic through the browser. Other protocols need the Netzilo client.
  • One gateway address per account through Settings > Permissions.