Routing and server push
Who creates a route, how server push combines with local include/exclude and route_file, and why client_subnet, allowed_networks and gateway solve different jobs.
Six mechanisms for six different jobs
Start with direction: do you need to push a route to a client, allow a destination, register a network behind a client, or tunnel all device traffic?
| Mechanism | Where | Direction and meaning |
|---|---|---|
route | [profile:*] | The server pushes a CIDR to every user in the profile |
route | [user:*] | The server pushes a CIDR to one user; the per-user list replaces the profile list |
client_subnet | [user:*] | The server registers an inbound path to the network behind this client |
allowed_networks | [user:*] | Allowlist of destinations to which the user may send packets |
gateway, include, exclude | client.conf | Device-local choice: full tunnel plus extra routes and exclusions |
route_file | client.conf + file | Locally adds a large CIDR list to split tunnel on Windows/macOS |
Split tunnel and full tunnel
The default depends on the client: Linux CLI starts in split-tunnel mode, while GUI clients start in full-tunnel mode. Split mode sends the TUN pool and server-pushed routes through Qeli; full-tunnel internet egress requires NAT on the server.
# split tunnel: TUN pool, server push and include only
gateway = false
include = 10.50.0.0/16
exclude = 203.0.113.0/24
# full tunnel: route all remaining traffic through Qeli too
gateway = true
kill_switch = true
# required for full-tunnel internet egress
routing.nat.enabled = true
# WAN is detected automatically; set it explicitly when needed
routing.nat.interface = ens3
Profile and per-user routes
The key is repeatable. CIDR is required and comes first; gateway and metric are optional. The usual next hop is the server's tun.address.
# sent to every user of the profile
route = 192.168.50.0/24 gateway=10.9.0.1 metric=100
route = 10.50.0.0/16
[user:contractor]
# sent to this user only
route = 192.168.50.128/25 gateway=10.9.0.1
route, profile route entries are not sent to that user. If both sets are needed, list them in the user section.| Situation | What happens |
|---|---|
route_local=false | Explicit server-pushed CIDRs are still applied; this flag controls only the broad RFC1918 ranges |
route=0.0.0.0/0 or prefix /1…/7 | The client core filters an overly broad push: a server cannot silently turn split tunnel into full tunnel |
INI contains advertised_routes or push_routes | Those INI keys do not exist; use repeatable route entries |
| Large list over UDP | AuthOK is fragmented; above roughly 28 KB the server refuses the connection and asks you to reduce the list or use TCP |
Loading IP addresses and subnets with route_file
route_file attaches one or more external files to local split tunnelling. The key is repeatable; entries from every file are merged with include, canonicalised and deduplicated.
# Windows
gateway = false
route_file = C:\qeli\corp-routes.txt
route_file = C:\qeli\openvpn-routes.txt
# macOS: use an absolute path
# route_file = /Users/alice/.config/qeli/routes.txt
# one network per line
10.20.0.0/16
192.0.2.0/24 # branch office
; write one IPv4 address as /32
203.0.113.17/32
route 172.16.9.7 255.255.0.0 vpn_gateway 10
route-ipv6 2001:db8:42::/48
| Rule | Meaning |
|---|---|
| Format | Accepts IPv4/IPv6 CIDR, route ADDRESS [NETMASK] [gateway] [metric] and route-ipv6 CIDR. Blank lines plus full-line and inline #/; comments are ignored |
| Repeatability | Specify route_file more than once. Paths may contain spaces; every file participates in the same canonicalisation and deduplication pass. |
| Bound | At most 250,000 unique routes in total after merging include and every route_file. |
| Address family | The file accepts IPv4 and IPv6 CIDRs. The client installs routes only for families present in the negotiated NetworkPlan; with ipv6=required, missing IPv6 fails closed. |
| Direction | Adds routes into the tunnel only, like include. Use exclude in client.conf to bypass the tunnel |
| Reloading | All files are read into one snapshot when NetworkPlan arrives; reconnect after editing. Reading, installation and cleanup of large lists can be cancelled with Disconnect. |
| Read failure | An unreadable file, malformed line or mask, or a bound violation stops the connection fail-closed; Qeli does not continue with a partial routing policy. |
| Successful load | The log contains Loaded N route(s) from … |
route_file is applied locally by Windows/macOS clients only. Rust CLI recognises it as a foreign platform key but does not read it; Android and iOS preserve it during profile round trips but do not apply it.route; a per-user list — route in users.conf; a large local Windows/macOS list — route_file; a portable local list — include/exclude in client.conf.Destination ACL and a network behind a client
allowed_networks limits a user's outbound destinations. client_subnet tells the server that a CIDR is reachable through this client. The keys are not interchangeable.
[user:branch-a]
static_ip = 10.9.0.10
# network behind branch-a: inbound iroute on the server
client_subnet = 192.168.50.0/24
# branch-a itself may access only this destination
allowed_networks = 10.9.0.0/24, 192.168.60.0/24
An empty allowed_networks means no ACL restriction, not deny all. A client_subnet must not overlap pool.cidr or networks at other sites.
Minimal site-to-site without NAT
Each gateway gets a fixed address, registers its LAN via client_subnet, and receives a personal route to the other site's LAN.
[user:branch-a]
static_ip = 10.9.0.10
client_subnet = 192.168.50.0/24
route = 192.168.60.0/24 gateway=10.9.0.1
[user:branch-b]
static_ip = 10.9.0.11
client_subnet = 192.168.60.0/24
route = 192.168.50.0/24 gateway=10.9.0.1
# server, [profile:main]
routing.nat.enabled = false
routing.forward_private = true
routing.client_to_client = true
# each Linux gateway, [qeli]
forward = true
Additional routing mechanisms
These settings do not replace route or route_file: they control NAT, app selection, IPv6 or interface lifecycle on a particular client.
| Mechanism | Where it works | Direction and meaning |
|---|---|---|
gateway_nat + lan_subnet | Linux | Source NAT for the LAN behind a client, turning the Linux client into a gateway for its network |
exit_node | Linux | Source NAT for tunnel traffic leaving through this client's physical WAN; the exit node itself must remain split tunnel |
apps + apps_mode | Windows / macOS / Android | Selects applications rather than networks; iOS preserves the setting but cannot apply it without an MDM NEAppRule |
allow_lan | Android | Keeps the home LAN, link-local traffic and local multicast outside the VPN |
dev_attach + QELI_TUNIP_FILE | Linux CLI | Delegates creation, addressing and routing of an existing TUN to an external controller |
allow_ipv6_leak | Client | Explicitly allows IPv6 over the physical path when a full tunnel is negotiated without IPv6; by default the missing family is blocked |
qeli:// and do not arrive through server push. Configure them in a trusted client.conf or the relevant platform UI.DNS proxy and the address a client receives
dns.enabled starts the server proxy. dns.push_servers separately determines which address is sent to clients.
dns.push_servers | dns.enabled | Result |
|---|---|---|
| set | either | The client receives the first address and queries it directly |
| empty | true | The client receives dns.listen and uses the server proxy, cache and blocklist |
| empty | false | No DNS is pushed; the device keeps its resolvers |
How clients apply pushed routes
All current clients apply the CIDR. Optional next-hop and metric behaviour is platform-specific, so do not use those fields as the only prioritisation mechanism.
| Client | CIDR | gateway/metric |
|---|---|---|
| Linux CLI | ✓ | Applies both fields |
| Android | ✓ | Reads both fields |
| Windows / macOS | ✓ | Reads them, but binds the route to TUN; logs the limitation |
| iOS source | ✓ | Uses the CIDR but does not apply the optional fields |
How to find where a route was lost
Check in order: server config, actual push, client routing table, forwarding, and NAT/firewall.
sudo qeli check-config --config /etc/qeli/server.confrejects invalid CIDRs and conflicting profilessudo qeli show-routes <user>shows routes for a specific userip route showchecks the Linux client routing tablesudo sysctl net.ipv4.ip_forwardexpected to be 1 on the gateway and serversudo iptables-save | grep -E 'qeli-nat|TCPMSS'checks NAT and MSS clampingLook for Pushed route applied on the client. If the push appears in the log but the OS has no route, the problem is platform application, a table conflict or permissions.