Leshy
DNS-driven split-tunnel router. Resolves domains, installs kernel routes -- only the traffic that needs a VPN goes through a VPN. Zero manual IP management, no over-routing, no leaks. Rust, Linux + macOS.
flowchart LR
App["App / Docker"] -- DNS query --> Leshy
Leshy -- match zone --> ZoneDNS["Corporate DNS"]
Leshy -- no match --> PublicDNS["Public DNS<br/>8.8.8.8"]
ZoneDNS -- "A 10.0.1.1" --> Leshy
PublicDNS -- "A 93.184.216.34" --> Leshy
Leshy -- "route add<br/>10.0.1.1 via tun0" --> Kernel["Kernel<br/>Routing Table"]
Leshy -- DNS response --> App
Quickstart
# Install
# Write your config
# Install and start as a system service (systemd / launchd)
# Point your DNS at Leshy
|
That's it. Leshy is running, will start on boot, and routes VPN traffic automatically.
Configuration
[]
= "127.0.0.53:53"
= ["8.8.8.8:53", "8.8.4.4:53"]
[[]]
= "corporate"
= ["10.0.0.2:53"]
= "dev" # route via VPN tunnel device
= "/run/vpn/corporate.dev" # file containing device name (e.g. "tun0")
= ["internal.company.com", "git.company.com"]
= ["corp"] # regex
[[]]
= "eu"
= "via" # route via static gateway
= "192.168.169.1"
= ["example.com"]
See config.example.toml for all options.
Route Types
| Type | Target | Use case |
|---|---|---|
dev |
Path to file containing device name | VPNs that connect/disconnect (tun0, wg0) |
via |
Gateway IP address | Always-on VPN or static gateway |
Domain Matching
domains-- exact match + all subdomains (company.commatchesgit.company.com)patterns-- regex match against the queried name
Why Leshy
Traditional split-tunnel tools like vpn-slice hardcode IPs in /etc/hosts, which breaks isolated networking (Docker builds, sandboxes). Leshy runs as a DNS server -- all apps get correct routing transparently.
Example: Route Everything Except .ru Through a European VPN
A common setup: you have a WireGuard/OpenVPN tunnel to a European server and want all traffic to go through it -- except Russian domains (.ru, .рф, .su) which should go direct.
flowchart LR
App -- DNS query --> Leshy
Leshy -- ".ru / .рф / .su" --> Direct["Default route<br/>(direct)"]
Leshy -- "everything else" --> EU["Europe VPN<br/>wg0"]
[]
= "127.0.0.53:53"
= ["8.8.8.8:53", "1.1.1.1:53"]
[[]]
= "eu-vpn"
= "exclusive" # route everything EXCEPT matched domains
= [] # use default upstream
= "dev"
= "/run/vpn/eu.dev"
# These domains are EXCLUDED from the VPN (go direct):
= []
= [
'\.ru$', # *.ru
'\.xn--p1ai$', # *.рф (punycode)
'\.su$', # *.su
]
Write the device file when your VPN connects (echo wg0 > /run/vpn/eu.dev) and every domain that isn't .ru/.рф/.su gets routed through wg0. Local and Russian traffic stays on your normal connection.
IP Exclusion Ranges
In exclusive zones, static_routes serve a dual purpose: they define IP ranges to exclude from routing. When a DNS-resolved IP falls within any CIDR in static_routes, Leshy skips route installation entirely.
This is useful when you want most traffic through a VPN, but need certain IP ranges (like RFC1918 private networks) to go direct:
[[]]
= "vpn-catchall"
= "exclusive"
= "via"
= "10.8.0.1"
= [] # don't exclude any domains
= [] # don't exclude any patterns
= ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
With this config:
internal.company.com→ resolves to10.0.5.50→ no route (in 10.0.0.0/8)printer.local→ resolves to192.168.1.100→ no route (in 192.168.0.0/16)example.com→ resolves to93.184.216.34→ route via 10.8.0.1 (not excluded)
This ensures private network traffic (home router, local printers, etc.) bypasses the VPN even when domains aren't explicitly excluded.
Features
- Zone-based routing -- different DNS servers and route targets per zone
- Hot reload --
auto_reload = truewatches config and applies changes live - Composable config -- split zones into
config.d/*.tomlfiles - DNS caching -- with per-zone and per-server TTL overrides
- Route aggregation -- compress /32 host routes into wider CIDR prefixes (
route_aggregation_prefix = 24) - Static routes -- add CIDR routes on startup (
static_routes = ["10.0.0.0/8"]) - IP exclusion ranges -- in exclusive zones,
static_routesskip route installation for resolved IPs in those CIDRs - Upstream failover -- tries DNS servers in order, falls over on failure
- VPN reconnect -- device file disappears/reappears as VPN disconnects/connects
- Linux + macOS -- rtnetlink on Linux,
/sbin/routeon macOS
Running as a Service
# Install with defaults (config: /etc/leshy/config.toml, service name: leshy)
# Custom config path
# Multiple instances with different names
# Remove a service
On Linux this creates a systemd unit with CAP_NET_ADMIN + CAP_NET_BIND_SERVICE. On macOS it creates a launchd plist with KeepAlive + RunAtLoad.
You can also run leshy directly:
VPN Integration
Write the tunnel device name when VPN connects:
Leshy reads this file on each DNS query. When the file disappears (VPN disconnects), route addition fails gracefully and DNS responses are still returned.
Guides
- OpenConnect (Cisco AnyConnect) Split Tunnel -- connect to a Cisco VPN without it taking over your default route; Leshy routes only corporate traffic through the tunnel
- SSH Tunnel + tun2socks -- turn an SSH connection into a routable tunnel device; route selected domains through a remote server without a full VPN
Internals
Architecture
flowchart TB
subgraph Leshy
Handler["DNS Handler"]
Matcher["Zone Matcher<br/>domain + pattern rules"]
Cache["DNS Cache"]
Agg["Route Aggregator<br/>/32 → /N compression"]
RM["Route Manager"]
end
Client(["Client"]) -- "query" --> Handler
Handler --> Matcher
Handler -- "cache hit?" --> Cache
Handler -- "forward" --> Upstream(["Upstream DNS"])
Upstream -- "response" --> Handler
Handler -- "A/AAAA records" --> Agg
Agg -- "RouteActions" --> RM
RM -- "rtnetlink / route cmd" --> Kernel(["Kernel"])
Handler -- "DNS response" --> Client
Project Structure
src/
config.rs Config parsing (TOML, zones, dns_servers)
dns/
handler.rs DNS request handler, upstream forwarding
cache.rs DNS response cache
routing/
mod.rs Route manager (add/remove routes per zone)
aggregator.rs CIDR route aggregation (/32 → wider prefixes)
linux.rs Linux rtnetlink operations
macos.rs macOS /sbin/route operations
reload.rs Hot-reload config watcher
zones/
matcher.rs Domain/pattern matching for zones
Route Aggregation
When route_aggregation_prefix is set (e.g. 24), instead of adding a /32 for each resolved IP, Leshy installs a wider prefix covering that IP. Future IPs in the same range and zone are no-ops. If an IP from a different zone falls into an existing aggregate, it splits into non-conflicting sub-prefixes.
Development
Disclaimer
This project is built strictly for research and educational purposes. It explores the possibilities of DNS-based dynamic routing and is not intended for bypassing any government restrictions or censorship. Users are solely responsible for ensuring their use complies with applicable laws and regulations.
License
MIT