Skip to content
VC
Case Study #28 · Networking · Infra

Selective routing through a self-hosted VPS: the tunnel carries only what must, everything else goes direct

Internal work on my own tooling: a home and work perimeter where a handful of external resources must travel through my own VPS while the rest of the internet stays direct and full-speed. The firmware's built-in mechanism did not solve this, so the routing was built by hand and fully automated over the router's HTTP API.

Industry
Own infrastructure · networking
Stack
WireGuard · Python (stdlib) · router HTTP API
Timeline
Core — 4 days, hardening — months in production
Outcome
Only what needs the tunnel takes it
01 · Pain Point

The tunnel is needed for a few resources, but it switches on for the entire internet

The requirement fits in one line: a few external resources must be reachable from devices on the network, everything else must behave exactly as before. The obvious answer — raise a VPN on the router and push all traffic into it — solves that requirement and immediately creates three new ones.

First: anything that depends on the exit location starts behaving differently — local services, payment gateways, search results, video. Second: latency and bandwidth for all household traffic now bottleneck on a single rented server, heavy video and calls included. Third: any tunnel hiccup no longer means "one site didn't open" — it means "there is no internet at all".

The firmware's own answer to this is domain-list routing: define a group of domains, bind it to the tunnel interface, and the firmware is supposed to inject the routes for you. In practice, on the main network segment this mechanism injects no routes at all — it only caches resolved addresses. You don't learn that from the documentation; you learn it from the symptom: the domain resolves, and the traffic still leaves through the ISP.

02 · Solution

Two mechanisms instead of one broken one: routes and surgical DNS

Selective routing breaks in two places at once: the name resolves to the wrong address, and the packet leaves through the wrong interface. So there are two mechanisms, and they cover different halves of the problem — static routes decide where the packet goes, per-domain DNS bindings decide which address you get in the first place.

01
Classify

Does the resource own AS ranges, or does it live on a shared cloud front? The method follows from that

02
Routes

Static routes by AS range straight into the routing table, interface = the tunnel

03
DNS

Per-domain bindings: only these names resolve through a public resolver over the tunnel

04
Automation

Router HTTP API, challenge-response auth, batched edits, explicit config save

05
Control

Leak diagnostics, config snapshot before every change, watchdog on the VPS side

Static routes — where the service owns its AS ranges

The only mechanism that genuinely moves traffic into the tunnel on the main segment is an entry in the routing table. For each service you look up the prefixes announced by its autonomous system and install every prefix as its own route pointing at the tunnel interface. What goes through the tunnel is exactly what the routes list — not one byte more.

Shared cloud fronts can't be handled with static routes

The flip side of the same rule: if a resource sits behind a large CDN, half the internet shares its address blocks. Routing such a block wholesale means dragging a mountain of unrelated traffic into the tunnel — precisely the degradation you were avoiding. Here only narrow subnets for the observed edge nodes work, or a separate network segment running a full tunnel. That's a deliberate trade-off, not a workaround.

DNS bindings — per-domain only, never global

A route is useless if the name resolves to a substituted address. So the domains that matter get a dedicated resolver reachable over the tunnel. The rules that cost debugging time:

  • bind per domain, never globally — a flapping tunnel would otherwise kill resolution for the whole network
  • never bind shared CDN or cloud-API apex domains — half the remaining internet travels through them
  • binding a brand apex picks up subdomains via suffix matching — the list doesn't have to be maintained by hand
  • test resolution only from the same segment: cross-segment DNS queries are refused, which produces a false diagnosis

Mobile and game clients connect by raw address

A separate class of surprises: apps frequently reach their API and game servers by raw IP, often over UDP, never asking DNS at all. A DNS binding does nothing for them — they need routes. WireGuard carries UDP transparently, so the only real question is how complete your range list is.

Automation over HTTP API, because SSH isn't dependable

SSH on the device is regularly unavailable: session slots get stuck and the port times out exactly when something needs fixing urgently. All configuration moved to the router's HTTP API, with a helper written against the Python standard library:

  • challenge-response auth: the device returns a realm and a challenge, the client computes a derived hash and never transmits the password
  • sessions live for minutes — the wrapper re-authenticates on 401 and replays the request
  • bulk edits go in batches of a couple dozen — the API deduplicates identical removal objects, so an oversized batch silently loses part of the work
  • changes survive a reboot only if the config save is called explicitly — it's a separate step, not a side effect

Leak diagnostics: what resolves but still bypasses the tunnel

The most useful script in the setup compares two things: the addresses the router has already resolved for the tracked domains, and the coverage of the static routes. Anything present in the first list and absent from the second is a leak. That's exactly how a separate media range turned up — the service itself opened fine while its images and attachments did not.

A config snapshot before every change, and a rollback written in advance

Before any batch of edits, a timestamped file captures the current state: domain groups, DNS-proxy settings, name-server bindings, the routing table. The DNS rollback to the ISP resolver is written down separately — a short sequence of calls you can execute when the network is already in a bad state and there is no time to reason about it.

03 · Stack

Nothing exotic — what matters is where it's applied

WireGuard

Tunnel to a self-hosted VPS; application UDP passes through transparently

Self-hosted VPS

The single tunnel exit: systemd unit plus a scheduled watchdog

Static routes

AS-range entries in the routing table — the only mechanism that actually redirects traffic

Per-domain DNS bindings

Selected names resolved over the tunnel instead of substituted ISP-resolver answers

Router HTTP API

Challenge-response auth, short sessions, automatic re-auth — replacing unreliable SSH

Python (stdlib)

urllib, http.cookiejar, hashlib — zero external dependencies, runs anywhere

Network segmentation

Three segments with different policies: full tunnel / selective / direct

Config snapshots

JSON dump of routes, bindings and domain groups before every batch of edits

WireGuardPython stdlibRouter HTTP APIStatic routesDNS bindingsAS rangessystemdwatchdog
04 · Results

The resources are reachable, and the rest of the internet didn't slow down

Traffic in the tunnel
all of it targeted

everything else goes direct through the ISP — no speed hit, no change of exit geography

Rules in production
≈ 340

≈ 155 static routes + ≈ 190 per-domain DNS bindings, all installed by script

Tunnel recovery
≤ 2 min

the watchdog repairs a broken service config on its own, without an SSH session

Policy is chosen by which network you join, not by reconfiguring the device

Three segments carry three policies: full tunnel, selective routing, and direct ISP exit. Devices aren't configured — they're connected to the right network. That also provides the escape hatch: if the selective route set misses something, the device moves to the full-tunnel segment temporarily and the investigation can happen without pressure.

The incident that overrode one of my own rules

After one reboot the internet was simply gone: nothing resolved, the clock wouldn't synchronize, only the tunnel still worked. The cause was external — the ISP resolver stopped returning valid answers. The fix contradicted a rule I had written down earlier ("never point a global resolver at the tunnel"): the DHCP-supplied ISP name servers had to be ignored and a public resolver brought up over the live tunnel. The rule in the cheatsheet was rewritten together with the condition under which it stops applying — arguably more valuable than the fix itself.

Changing the exit point is a migration of every rule, not a toggle

Moving the setup to a different VPS revealed the price of the routes-plus-DNS pairing: both the routes and every domain binding have to be repointed. A binding left on a dead interface raises no error — it just quietly breaks resolution for that one domain. The migration ran through the same script, in batches, with verification after each step.

The watchdog learned from a real failure

Another failure: after a VPS reboot the tunnel service refused to come up — an empty peer block had been left in the config, and the service crashed on every start. The watchdog missed it, because it only knew how to react to a missing interface and a stale handshake. It now sanitizes the config before rebuilding it and triggers on the service being in a failed state. That whole class of failure became self-healing.

Stripping the details: the result is a predictable network perimeter where the list of "what goes through the tunnel" is explicit, verifiable, versioned configuration — not a pile of accumulated settings nobody dares to touch.

05 · Where it fits

Where else the same methodology applies

This case isn't about a home router. It's the generic problem of "send part of the traffic down a special path, leave the rest alone, and make all of it reproducible". The approach transfers with almost no changes:

  • An office or branch that needs specific external services but can't put its entire network behind one rented link
  • Geo-sensitive external APIs — only those calls leave through a dedicated exit address; the rest of the service keeps its normal path
  • Separating work and personal traffic on one physical link — via segments with different policies, no agents on the devices
  • Any network device with an HTTP API — configuration becomes a script: reproducible, versionable, revertible
  • Auditing an existing perimeter: what actually enters the tunnel versus what is merely assumed to — two different lists that need reconciling
What's reused on subsequent projects
  • A stdlib helper for challenge-response auth against a device HTTP API: automatic re-auth on 401, batched edits, explicit config save
  • The leak-diagnostics script: resolved addresses reconciled against routing-table coverage — it finds what is believed to be tunneled but isn't
  • A config snapshot before every batch of edits plus a written rollback procedure that is executable on an already-broken network
  • The separation rule: own AS ranges go into routes, shared cloud fronts go into a dedicated segment — never into wide address blocks
  • A watchdog that triggers on the service's failed state and sanitizes the config, instead of only checking that an interface exists
Similar challenge?

If you need access to specific resources, not your whole internet through someone else's link — this is configurable

Start with an inventory: which resources genuinely need the tunnel, whether they own address ranges, and what is already leaking past it. From there the configuration turns into a script — with backups, a rollback and a watchdog — instead of manual settings nobody remembers.

Ready to start?

The 5,000 ₽ audit — with a concrete report and quote

I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).

Or just send your question — I reply within 2 hours