Selective routing through my own server: what needs the tunnel goes through it, the rest goes direct
I built this for my own infrastructure. On my home and work network, some external services have to go through a server I rent (a VPS), while the rest of the internet goes direct at full speed. The router's built-in feature couldn't handle this. So I set up the routing myself and automated it completely through the router's HTTP API.
The tunnel is needed for a few services, but it switches on for the whole internet
The task fits in one line: a few external services have to be reachable from devices on the network through my own server, and everything else should work as usual. The simplest answer is to set up a VPN on the router and send all traffic through it. That solves the task and immediately creates three new problems.
First, anything that depends on where the traffic comes from starts behaving differently: local services, payment gateways, search results, video. Second, the speed and latency of all household traffic now depend on one rented server, including heavy video and calls. Third, any hiccup in the tunnel means there's no internet at all.
The router firmware offers routing by domain lists for this: you create a group of domains, link it to the tunnel, and the firmware is supposed to add the routes itself. In practice, on the main network this mechanism adds no routes. It only remembers the addresses that DNS returned. You find this out the hard way: the domain's address resolves, and the traffic still bypasses the tunnel.
Two mechanisms working together: routes and per-domain DNS
Selective routing can break in two places: a domain name turns into the wrong address, or a packet leaves through the wrong exit. So there are two mechanisms, and each covers its own half. Static routes decide where a packet goes. Per-domain DNS bindings decide which address the device gets in the first place.
Does it have its own address ranges, or does it run on a shared cloud platform? That decides the method
Static routes for the service's address ranges, straight into the routing table, with the tunnel as the exit
Per-domain bindings: only these domains are looked up through a public DNS server over the tunnel
The router's HTTP API, challenge-response login, edits in batches, saving the config
Leak checks, a config snapshot before every change, a watchdog on the server
Static routes for services with their own address ranges
On the main network, the only thing that reliably sends traffic into the tunnel is an entry in the routing table. For each service I look up its own address ranges by its autonomous system (AS) number. An autonomous system is a network registered to a particular company. Each range is added as a separate route through the tunnel. Only addresses covered by these routes go through the tunnel.
Routes can't cover a shared cloud platform
The same rule has a flip side. If a service runs through a large content delivery network (CDN), many other sites share the same addresses. Sending such a block into the tunnel whole would pull in a lot of unrelated traffic and slow the network down, which is exactly what I wanted to avoid. Here the only options are narrow subnets for the addresses actually seen in use, or a separate network segment where the tunnel covers everything. That's a deliberate choice.
DNS bindings, one domain at a time
A route is useless if the domain name turns into an address the routes don't cover. So the domains that matter get a separate DNS server that is reached through the tunnel. The rules that took the most debugging:
- bind each domain separately: with one shared binding, any tunnel failure breaks DNS for the whole network
- never bind the root domains of shared CDNs or cloud APIs: many other services run through them
- a binding for a service's main domain also covers its subdomains, so the list doesn't have to be kept up by hand
- test DNS only from the same segment: the router blocks queries between segments, and a test from the next one gives a false result
Mobile apps and games connect by address
A separate source of surprises: apps often connect to their servers straight by IP address, frequently over UDP, without asking DNS at all. A DNS binding won't help here; they need routes. WireGuard carries UDP without trouble, so the only question is how complete the list of address ranges is.
Setup through the HTTP API, because SSH is unreliable
SSH on the router is regularly unavailable: sessions hang and the port stops responding just when something needs an urgent fix. So I moved all the setup to the router's HTTP API and wrote a helper script using only Python's standard library:
- challenge-response login: the router sends a random string, the script computes a hash from it and the password, and the password itself never crosses the network
- a session lasts a few minutes, so on a
401response the script logs in again and repeats the request - bulk edits go in batches of twenty or thirty entries: the API merges identical delete commands, and an oversized batch quietly loses some of the changes
- a change survives a reboot only if the config is saved in a separate call
Finding leaks: the address resolves, and the traffic still bypasses the tunnel
The most useful script compares two lists: the addresses the router has already received from DNS for the tracked domains, and the addresses covered by static routes. Anything in the first list and missing from the second bypasses the tunnel. That's how I found a separate range the service used for images and attachments: the service itself opened, and they didn't.
A config snapshot before each change, and a rollback ready in advance
Before each batch of edits, the current state is saved to a timestamped file: domain groups, DNS settings, name server bindings, the routing table. The steps to switch DNS back to the provider's servers are written down separately: a few commands you can run when the network is already misbehaving and there's no time to think.
Ordinary tools, put in the right places
The tunnel to my server; apps' UDP traffic passes through without trouble
The tunnel's only exit point: a systemd service and a scheduled watchdog
Entries for address ranges in the routing table, the one mechanism that reliably sends traffic into the tunnel
Addresses for the chosen domains are looked up through the tunnel
Challenge-response login, short sessions, automatic re-login. Setup uses the router HTTP API
urllib, http.cookiejar, hashlib: no external dependencies, so the script runs wherever Python is installed
Three segments with different rules: full tunnel, selected traffic only, everything direct
Routes, bindings and domain groups saved to JSON before each batch of edits
The services are reachable, and the rest of the internet didn't slow down
everything else goes direct through the provider, at full speed and from the usual address
about 155 static routes and about 190 per-domain DNS bindings, all added by script
the watchdog repairs a broken config by itself, with no need to log in to the server
You choose the mode by the network you join
The network has three segments, that is, three separate subnets with different rules: full tunnel, selected traffic through the tunnel, and everything direct through the provider. Nothing has to be set up on the device itself: you just connect it to the right network. This also gives a fallback. If the selective routes miss something, the device can move to the full-tunnel segment for a while, and the problem can be sorted out calmly.
The failure that made me rewrite my own rule
After one reboot the whole internet disappeared: no address resolved, the clock wouldn't sync, and only the tunnel worked. The cause was external: the provider's DNS server stopped giving correct answers. The fix went against my own rule, "never send all DNS through the tunnel". I had to turn off the DNS servers the provider hands out automatically (over DHCP) and send queries to a public DNS server through the working tunnel. I rewrote the rule in my notes and added the condition under which it doesn't apply. That may be worth more than the fix itself.
Changing the server means moving every rule
When the tunnel had to move to a different VPS, the cost of pairing routes with DNS became clear: every route and every domain binding had to be moved. A binding left on a switched-off tunnel gives no error and quietly breaks DNS for that one domain. I did the move with the same script, in batches, checking after every step.
The watchdog learned from a real failure
Once, after the VPS rebooted, the tunnel didn't come up: an empty block had been left in the config, and the service crashed on every start. The watchdog didn't notice. It only reacted when the network interface disappeared or when the tunnel hadn't exchanged keys (the handshake) for too long. Now it also cleans up the config before a restart and reacts when the service goes into a failed state. The system now fixes failures like this by itself.
In the end, the list of what goes through the tunnel is written down explicitly. It can be checked, and every version of it is kept.
Where else the same approach works
Behind this case is a common task: "send part of the traffic by a special route, leave the rest as it is, and be able to repeat the whole setup". The approach carries over almost unchanged:
- → An office or branch that needs certain external services through a dedicated channel and can't put the whole network behind one rented server
- → External APIs that accept requests only from a specific address: only the calls to them go through the dedicated address, and all other traffic goes as before
- → Keeping work and personal traffic apart on one connection: separate segments with different rules, with no software on the devices
- → Any network device with an HTTP API: the config becomes a script that can be repeated, versioned and rolled back
- → Checking an existing setup: I compare what traffic actually goes into the tunnel with the list of what is supposed to
- A standard-library Python module for challenge-response login to a device's HTTP API: re-login on 401, edits in batches, an explicit config save
- The leak-check script: it compares the addresses from DNS with the routing table and finds traffic that should go into the tunnel and bypasses it
- A config snapshot before each batch of edits, plus written rollback steps that work even on a network that's already broken
- The rule of thumb: a service with its own address ranges gets routes, a shared cloud platform gets a separate segment
- A watchdog that reacts to a failed service and cleans up the config before restarting it
Need to send only part of your traffic through a separate channel? That can be set up
Start with an inventory: which services really need the tunnel, whether they have their own address ranges, and what is already slipping past it. Then the config becomes a script, with a backup, a rollback and a watchdog.
Related cases and ready-made solutions
An online shop audit and the three fixes that, by my estimate, pay off first
In a two-week audit I work out where key staff spend their time and draw up a list of tasks with a payback…
I stress-test AI chatbots and find the vulnerabilities before anyone exploits them
I attack a copy of the system following the OWASP LLM Top 10, a public list of typical threats to AI…
A Telegram bot for foremen: stock, budgets and payments from 1C right on site
A foreman opens the Telegram bot, sees data from 1C UNF in two taps and confirms deliveries with a photo of…