technical

Geographic Labyrinth, Part 1: A WireGuard Pinball Machine on a Single Box

Part 1 of a build-log series. This is a live build — Parts 2 and 3 land as the chain comes up.

The pinball machine

I have a small Linux box sitting next to my router. Inside it are six Docker containers chained together with WireGuard tunnels in this shape:

client → primary → russia → canada → taiwan → australia → exit → router → ISP

Each middle container is assigned a real public IP from a real country's allocation: ROSTELECOM AS12389 for Russia, ROGERS AS812 for Canada, CHUNGHWA AS9924 for Taiwan, TELSTRA AS1221 for Australia. NAT rewrites the source on the way out so a traceroute through the chain sees the packet apparently bouncing across the planet, and a MaxMind GeoIP lookup on any of those hop IPs will resolve to the real owning country.

In reality, all six containers are spinning on the box next to my router. The pinball is theater for anyone observing the LAN-side wire.

This post covers what I've actually built so far (which is "containers networked, WireGuard about to come up"), the threat model it does and doesn't address, the research I did when I realized the project I thought I was extending didn't exist, and the five concrete footguns I've either hit or know are coming.

What it does — and what it definitely doesn't

The illusion is real, but the segment it covers is narrow. Here's the honest matrix:

Observer What they see
App running on a client My real public WAN IP
The destination server I'm talking to My real public WAN IP
My ISP My real WAN IP, normal traffic
Someone tcpdumping the LAN between client and exit container The fake pinball chain
Someone with shell on my router or an EDR collecting connection metadata The fake pinball chain
An insider/physical tap on the modem-side wire (if I move the box pre-WAN later) The fake pinball chain

The illusion lives entirely inside the segment between my client and the exit container. NAT at the modem flattens everything to my WAN IP before it touches the internet. So this is not a tool for evading destination services, ISPs, or anyone observing past my edge.

Common misread to head off: "Could I use this to appear from Russia (or Canada, etc.) to Cloudflare / Google / a geo-blocked service / a site I'm scraping as a sock-puppet?" No. The destination sees my real WAN IP, because that's what the exit hop masquerades to before the packet hits the modem. The fake-country IPs never leave the box. If you need actual cross-border egress — for OSINT sock-puppet work, geo-blocked content, or attributable-from-another-country research — you need a real VPS in that country, or a commercial multi-hop provider like Mullvad or IVPN that operates servers there. This project doesn't replace any of that.

What it actually defeats is local-segment observers doing IP-or-AS-based attribution. EDR products that ship connection metadata to a cloud SIEM. A managed/ISP-provided router with telemetry. A malicious device on the LAN. A pentester who got onto my home network and is mapping where my traffic appears to go. Their dashboards will quietly classify me as "this user routes through Russia, Canada, Taiwan, Australia."

That's a much narrower win than the README I started with implied. I want to be honest about that up front, because the alternative is misleading people into thinking this is a privacy tool. It isn't. It's a misdirection tool against a specific class of observer.

How I got here, and the research detour

Editor's note (2026-06-08, post-publication correction): The original version of this section claimed OblivionEdge wasn't real and I was writing from scratch under that name. That was wrong. OblivionEdge is a real Zero Trust SOHO router OS — DBA1337TECH/OblivionEdge, maintained by a collaborator from this community (disclosure: relationship is collaborator, not arms-length). The fictional pieces in my early draft were specifically the vendor-LLC footer and the daemon names I'd sketched (oblivion_ztna_daemon, oblivion_suspect_monitoring, oblivion_admin_cli); those are not upstream components. The router platform very much is.

What "built on" means here, concretely: OblivionEdge is my actual physical router — separate machine, separate codebase. The Labyrinth box (the "small Linux box sitting next to my router" in the opening paragraph) is a different appliance. The connection is that the Docker image used at every Labyrinth hop, oblivion-router:dev, is built from the OblivionEdge codebase — same WireGuard + ZTNA stack, additional per-hop SNAT / MSS-clamp / policy-routing layers on top. The build recipe lands in Part 2.

I started by spinning up a README under the project name OblivionEdge Geographic Router Labyrinth, complete with PlantUML diagrams, install commands, and a vendor-style "Built with OblivionEdge by OBLIVION EDGE LLC" footer. The upstream router OS gave me a real cryptographic and policy baseline to extend — WireGuard as the encrypted tunnel layer, X.509-pinned ZTNA, TPM-backed attestation. (Full upstream stack: Rust on a PREEMPT_RT Alpine kernel, Infineon SLB 9670 TPM, FIPS-hardened OpenSSL, post-quantum-prep alignment.) What I was inventing on top of that was the multi-hop chain plus the geographic-misdirection plane. The vendor-LLC footer was theater; the router underneath isn't.

That cleared up a lot. It also kicked off a second round of research into what actually exists in the multi-hop WireGuard space that I could lift from instead of reinventing.

Worth reading before you build

On the IrregularChat wiki:

  • WireGuard in Containers — the evergreen gotchas reference (sysctls, cap_add, rp_filter, MTU stacking, the birthplace-socket trick, asymmetric AllowedIPs). All the patterns this post uses, distilled into one page for reuse on other projects.
  • VPN Recommendation — where self-hosted multi-hop fits alongside Mullvad, IVPN, and Tor-over-VPN. Has the comparison matrix and the "not a commercial-VPN replacement" framing.

External:

  • Pro Custodibus — Multi-Hop WireGuard (2022). The canonical how-to. The load-bearing pattern is per-hop custom routing tables via Table = 123 in the wg-quick config plus PreUp = ip rule add iif wg0 table 123 priority 456. Forwarded traffic from a peer goes into its dedicated table; locally-generated tunnel handshakes still use the main table and reach the upstream peer endpoint directly. This is what keeps the chain from eating its own tail.
  • wireguard.com/netns. The fundamental kernel property the whole design relies on: a WireGuard interface's UDP socket stays bound to the namespace it was created in, even after you move the interface to a different namespace. That means encrypted UDP for hop N naturally exits through hop N-1's network. No veth gymnastics required.
  • JeWe37/wireguard-onion. An abandoned single-commit PoC, but it's the closest structural match — three nested namespaces, no veths, no policy routing, no MASQUERADE on intermediate hops. It exploits the birthplace-socket trick to compose the chain in 30 lines of bash. The architecture is sound even if the implementation is bare.
  • dadevel/wg-netns. Active, declarative wg-quick with namespaces. JSON config, systemd integration, podman/docker attachment via --network ns:/run/netns/<name>. Production-grade scaffolding for the namespace pattern.
  • Mullvad's multi-hop docs and their GitHub MTU issue #8362. Their MTU ladder is 1380 single-hop, 1320 outer / 1245 inner for 2-hop. Extrapolating, a 4-hop chain bottoms out at ~1180 on standard Ethernet, ~1160 with PPPoE. Below 1280 you're breaking IPv6 minimum-MTU guarantees. This is the empirical floor.

The genuinely novel piece

I expected someone had already combined "WireGuard chain" with "spoof real country IPs to fool GeoIP." Nobody has, in public. Onion-WireGuard exists. Multi-hop WireGuard exists. GeoIP confusion exists at the GRE/VXLAN red-team layer (see APNIC's January 2026 post "From Spoofing to Tunnelling"). But the specific combination — WireGuard chain hops each SNAT'd to a real country-AS prefix, generating MaxMind-believable source addresses on a forwarded packet — isn't documented anywhere I could find.

That's the part I'm actually writing from scratch.

The build, as of today

Containers are up. They can talk to each other on the docker bridge. WireGuard is being added one hop at a time. The state of the build, as of writing, is "WG keys generated, configs being templated, oblivion-primary is in a restart loop."

The restart loop is from a bug I hit this morning. The entrypoint script tries to write to /proc/sys/net/ipv4/ip_forward directly, which fails inside an unprivileged container because /proc/sys is read-only:

oblivion-primary | /usr/local/bin/entrypoint.sh: line 45: can't create /proc/sys/net/ipv4/ip_forward: Read-only file system

Two ways to fix this in compose, in increasing nuclear order:

# Preferred — minimal capability bump, declarative sysctls
cap_add: [NET_ADMIN]
sysctls:
  net.ipv4.ip_forward: 1
  net.ipv4.conf.all.src_valid_mark: 1    # WireGuard cryptokey routing
  net.ipv4.conf.all.rp_filter: 0         # see footgun #2 below

# Nuclear
privileged: true

Option A is enough for WG plus NAT, and it doesn't make the container root-equivalent on the host. Once the sysctls block is in place, drop the echo > /proc/sys/... line from the entrypoint entirely — the runtime already set them before the script runs.

Five footguns

Some of these I've hit; some I know are coming.

1. /proc/sys is read-only in unprivileged containers

Today's bug. Documented above. The fix is sysctls: in compose, not echo > /proc/sys/... in the entrypoint.

2. Reverse-path filter drops every packet in a chain topology

net.ipv4.conf.all.rp_filter does a sanity check: "would I have routed a packet to this source via the interface it arrived on?" In a chain, the answer is usually no — packets enter on wg0 but the kernel's route to their source goes via wg1. The packet gets silently dropped, the chain looks broken, and wg show looks healthy because the handshake is fine. The fix is rp_filter=0 on every hop. Worth setting alongside ip_forward.

3. SNAT to the country IP — don't just add it as an alias

If I add 5.101.142.37 as a secondary address with ip addr add 5.101.142.37/32 dev wgout and call it done, outbound packets still leave with the primary 10.x source. MaxMind sees RFC1918 space. The illusion collapses to "oh, it's a tunnel."

The fix is explicit SNAT:

iptables -t nat -A POSTROUTING -o wgout -j SNAT --to-source 5.101.142.37

There's a subtle kernel sysctl that affects related behavior — net.ipv4.icmp_errors_use_inbound_ifaddr=1 switches the default ICMP error source from outbound to inbound interface. But it only picks the primary address. For alias-sourced responses, the sysctl doesn't help. Which leads into:

4. Traceroute believability needs an ICMP TTL-exceeded responder per hop

Here's where the project's LARP holds or collapses. A real router that's being traceroute'd responds to UDP packets in the 33434–33534 range with an ICMP Time Exceeded (type 11), sourced from its own IP, when TTL hits 1. Without that, traceroute through the chain prints * * * at every hop and the geographic illusion dies in three seconds.

iptables REJECT --reject-with icmp-host-prohibited generates type 3 (destination unreachable), not type 11. There's no iptables target that emits a custom-sourced Time Exceeded. The only way to do this from an alias IP is a small responder daemon. Minimum-viable in Scapy:

from scapy.all import *

FAKE_HOP_IP = "5.101.142.37"

def reply_ttl_exceeded(pkt):
    if UDP in pkt and 33434 <= pkt[UDP].dport <= 33534:
        resp = (
            IP(src=FAKE_HOP_IP, dst=pkt[IP].src) /
            ICMP(type=11, code=0) /
            pkt[IP][:28]
        )
        send(resp, verbose=0)

sniff(filter="udp portrange 33434-33534", prn=reply_ttl_exceeded, store=0)

Plus an iptables rule to suppress the kernel's own ICMP for those packets, otherwise the responder fires twice:

iptables -I FORWARD -p udp --dport 33434:33534 -m ttl --ttl-eq 1 -j DROP

This is the hardest part to get right and the easiest part to forget. I haven't built it yet. It goes in part 3 of the series.

5. MTU stacking will eat large flows alive

Each WireGuard hop adds 60 bytes of overhead (20 IP + 8 UDP + 32 WG header). Five hops = 300 bytes. Without MSS clamping on FORWARD at every container boundary, large TCP flows hit PMTU blackholes on real-world internet paths (lots of CDNs silently drop ICMP Frag Needed). The fix is per-hop:

iptables -t mangle -A FORWARD -o wgout -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --clamp-mss-to-pmtu

Set WireGuard MTU at each hop to ~1280 to leave headroom for IPv6 minimums. If I ever move the box pre-WAN with PPPoE, the floor drops another 8 bytes and it gets even tighter.

Containment, because spoofed IPs are not a joke

The fake-country addresses are real public IPs owned by real organizations. If a packet with source 5.101.142.37 ever physically leaves my WAN NIC, that is source-IP spoofing on the public internet. That's a real problem — a BCP38 problem, and depending on jurisdiction and intent, potentially a worse problem.

The structural guarantee that keeps me safe is namespace isolation: the container running with the fake source has no route to the physical NIC. Its only egress is the next veth, which goes to the next hop, which eventually terminates at the exit container that masquerades back to my real LAN IP before anything touches my router. The fake source never sees a real interface.

I add iptables DROP rules as defense-in-depth, not as the primary control:

# Host-side belt and suspenders — never let a fake-country source out the WAN
for src in 5.101.0.0/16 174.95.0.0/16 1.160.0.0/11 1.128.0.0/11; do
  iptables -I FORWARD -s $src -o eth0 -j DROP
  iptables -I OUTPUT  -s $src -o eth0 -j DROP
done

# Also blackhole the ranges in the host route table so accidental loose
# routing can't reach the real internet hosts in those ranges
for net in 5.101.0.0/16 174.95.0.0/16 1.160.0.0/11 1.128.0.0/11; do
  ip route add blackhole $net
done

The blackhole routes serve a second purpose: if any process inside my LAN tries to legitimately reach a real Rostelecom IP, the host route eats it instead of the packet hitting my SNAT and bouncing back confused. Not great for normal browsing of those allocations, but the labyrinth box isn't a daily-driver router for me — it's a research appliance.

The combination — namespace isolation primary, iptables drop as audit, blackhole routes as containment — is sufficient for local-only testing. The moment any of those would become load-bearing on a public-facing box, the whole design needs review.

What's next

The natural arc for this series is three posts:

  1. (This post) — what it is, why I'm building it, what the research turned up, the footguns.
  2. WireGuard chain plus host policy routing — get all six hops handshaking, build the GeoIP SNAT layer, route selected client traffic via the entry hop using iptables -m mark + ip rule. The "lab next to my router" configuration.
  3. The traceroute responder, the MTU shakedown, and (maybe) pre-WAN promotion — build the Scapy ICMP responder, benchmark MTU floor empirically, and decide whether to move the box between modem and router with a bypass switch.

Open questions I'd love community input on:

  • GeoIP attribution: anyone done empirical testing on MaxMind, ipinfo, ip-api, etc., to confirm they all classify by simple prefix lookup vs. doing any BGP cross-check at query time? The research suggests pure prefix lookup but I haven't validated.
  • EDR connection-metadata collection: which EDR products would actually be fooled vs. which ones look at TLS SNI / hostname-resolution patterns and would see through this immediately?
  • Better ICMP TTL-exceeded approach than Scapy: anyone built this in eBPF or a small C daemon? Scapy is fine for a PoC but adds Python overhead per packet I'd rather skip.
  • PPPoE pre-WAN bypass switching: if I move the box pre-WAN, what's the cleanest way to fail open (full-bridge mode) on container-health failure? I've seen keepalived patterns but nothing tuned for this layout specifically.
  • OblivionEdge license / CLA stance for downstream image builds: the upstream is "public domain forever" with an OEM Software License Agreement caveat. What's the buildable boundary for a downstream image like oblivion-router:dev that adds non-upstream layers (SNAT, MSS clamp, traceroute responder)? If anyone has reconciled similar public-domain-plus-OEM language for a derivative work, I'd take the pattern.

If you're running anything in this neighborhood — chained WireGuard, source-spoofed simulation environments, anything that fakes geography for a defensive use case — drop a comment.

The compose file goes up when handshakes are green.

0 Comments

← Back to all posts