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.
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"]) - 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.
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
License
MIT