Dean Bird - VK4DSB

Amateur radio operator, IT security professional, and tinkerer based in Bellmere QLD.

View on GitHub

Previously I built an Alpine-based TOR transparent gateway for a single network that forced all traffic from the network out over TOR. It worked, but it was klunky became:

The new topology

The existing network worked well, however it was time for some improvements to allow me to do some more interesting things on the network, So first, I looked to re-design the network and came up with.

The key changes: The TOR GW is now dual-homed and has assumed the .1 address in each of the networks making routing much easier to manage.

Step 1: The transit subnet

First, I reconfigured the ER-X to adjust the 172.20.0.0/24 network to be a /30

A /30 gives exactly two usable addresses, which is all a point-to-point link needs.

A single /16 summary route covers all three internal subnets (and any future ones) — directly connected routes always win, so the /30 transit doesn’t conflict.

Step 2: Change the TOR GW (Alpine) networking

In Proxmox, I added three interfaces all with different vLans to the VM running the gateway. Once added, I adjusted the network to provide each of them an IP address.

The TOR GW’s /etc/network/interfaces:

auto lo
iface lo inet loopback
 
# WAN Side - Transit link to ER-X
auto eth0
iface eth0 inet static
        address 172.20.0.2/30
        gateway 172.20.0.1
 
# LAN Side - TOR clients General (CTF)
auto eth1
iface eth1 inet static
        address 172.20.70.1/24
 
# LAN Side - iNet representation for CTF
auto eth2
iface eth2 inet static
        address 172.20.71.1/24
 
# LAN Side - Internal TOR network
auto eth3
iface eth3 inet static
        address 172.20.72.1/24

Only the WAN interface gets a gateway line — the LAN interfaces are the gateway for their respective segments.

Step 3: Setup DHCP for each of the networks.

dnsmasq was already installed (it’s in the original setup), so I just had to configure it to serve all three LANs with the right options per-segment:

# DHCP only - DNS is handled by Unbound (port 53)
interface=eth1
interface=eth2
interface=eth3
bind-dynamic
 
# Disable DNS server (Unbound has port 53)
port=0
 
# eth1 - CTF General
dhcp-range=set:lan0,172.20.70.50,172.20.70.200,255.255.255.0,12h
dhcp-option=tag:lan0,option:router,172.20.70.1
dhcp-option=tag:lan0,option:dns-server,172.20.70.1
 
# eth2 - iNet representation
dhcp-range=set:lan1,172.20.71.100,172.20.71.200,255.255.255.0,12h
dhcp-option=tag:lan1,option:router,172.20.71.1
dhcp-option=tag:lan1,option:dns-server,172.20.71.1
 
# eth3 - Internal TOR network
dhcp-range=set:lan2,172.20.72.100,172.20.72.200,255.255.255.0,4h
dhcp-option=tag:lan2,option:router,172.20.72.1
dhcp-option=tag:lan2,option:dns-server,172.20.72.1
 
dhcp-authoritative
log-dhcp
dhcp-leasefile=/var/lib/misc/dnsmasq.leases

Gotcha: This step required a bit of troubleshooting as I originally used bind-interfaces, which requires every listed interface to exist at startup. When eth3 wasn’t up (because of the typo above), dnsmasq crashed on launch. Even after fixing the typo, a stale dnsmasq process lingered and blocked the listening sockets. This resulted in me having to pkill dnsmasq

Step 4: DNS that actually works

Originally, I made a mistake in the way Unbound was configured. It set up Unbound but never told it where to forward queries. So Unbound would try recursive resolution against root servers, which mostly fails (and would leak DNS outside TOR even if it succeeded).

The fix: forward all queries to TOR’s DNSPort.

/etc/unbound/unbound.conf:

server:
    verbosity: 1
    interface: 0.0.0.0
    port: 53
    do-ip4: yes
    do-not-query-localhost: no
    access-control: 127.0.0.0/8 allow
    access-control: 172.20.70.0/24 allow
    access-control: 172.20.71.0/24 allow
    access-control: 172.20.72.0/24 allow
    cache-min-ttl: 3600
    cache-max-ttl: 86400
    hide-identity: yes
    hide-version: yes
 
forward-zone:
    name: "."
    forward-addr: 127.0.0.1@5353

Step 5: Update iptables

Once all of the infrastructure was in place, time to update the iptables rules. I wanted to make sure that

  1. Bypasses TOR for local-network traffic — clients can reach other internal LANs and the home LAN normally. This is managed via the Firewall on the ER-X
  2. Redirects all TCP into TOR’s TransPort — not just 80/443. SSH, mail, anything TCP becomes onion-routed.
  3. Redirects all DNS to TOR’s DNSPort — even clients hardcoding 8.8.8.8 get their queries forced through TOR.
  4. Drops any LAN-to-WAN forwarded traffic — the kill switch. If TOR crashes, nothing leaks. Key rules (simplified):
# Local bypass (must come first)
iptables -t nat -A PREROUTING -i eth1 -d 172.20.0.0/16 -j ACCEPT
iptables -t nat -A PREROUTING -i eth2 -d 172.20.0.0/16 -j ACCEPT
iptables -t nat -A PREROUTING -i eth3 -d 172.20.0.0/16 -j ACCEPT
 
# Redirect DNS to TOR
iptables -t nat -A PREROUTING -i eth1 -p udp --dport 53 -j REDIRECT --to-ports 5353
# (repeat for eth2, eth3, and TCP DNS)
 
# Redirect all new TCP connections to TOR TransPort
iptables -t nat -A PREROUTING -i eth1 -p tcp --syn -j REDIRECT --to-ports 9040
# (repeat for eth2, eth3)
 
# NAT outbound traffic on WAN
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
 
# Default-drop FORWARD with explicit allows
iptables -P FORWARD DROP
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
 
# Allow management networks to reach internal LANs
for net in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
    iptables -A FORWARD -i eth0 -o eth1 -s $net -j ACCEPT
    iptables -A FORWARD -i eth0 -o eth2 -s $net -j ACCEPT
    iptables -A FORWARD -i eth0 -o eth3 -s $net -j ACCEPT
done
 
# Kill switch: no direct LAN-to-WAN traffic
iptables -A FORWARD -i eth1 -o eth0 -j DROP
iptables -A FORWARD -i eth2 -o eth0 -j DROP
iptables -A FORWARD -i eth3 -o eth0 -j DROP

The use of --syn on the catch-all REDIRECT is important: it only matches new connections (TCP SYN packets). Established connections are tracked by conntrack and don’t need to re-traverse PREROUTING.

I also allow SSH to the GW itself from any RFC1918 source on the WAN side allowing me to SSH into the router from my internal networks. Again this is now controlled from the ER-X firewall and means I don’t need to reconfigure two spots to change my internet networks.

iptables -A INPUT -i eth0 -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --dport 22 -s 172.16.0.0/12 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --dport 22 -s 192.168.0.0/16 -j ACCEPT

This was saved into a script to allow me to rapidly apply these settings. Once run, save the rules with /etc/init.d/iptables save and rc-update add iptables to persist across reboots.

Step 6: Testing.

From a client on any LAN:

ping <gateway>                      # should respond
nslookup google.com                  # should resolve
curl https://check.torproject.org    # should confirm TOR
curl -s https://api.ipify.org        # should return a TOR exit IP

On the GW, useful debugging commands:

grep -i bootstrap /var/log/tor/notices.log | tail   # is TOR ready?
cat /var/lib/misc/dnsmasq.leases                    # current DHCP leases
iptables -t nat -L PREROUTING -v -n                 # are REDIRECT rules being hit?
tail -f /var/log/messages | grep dnsmasq            # live DHCP activity
dig @127.0.0.1 -p 5353 google.com                   # test TOR's DNSPort directly

The iptables packet counters (-v flag) are particularly useful — if the REDIRECT rule shows 0 packets even when a client is trying to browse, you know traffic isn’t reaching the right place.

What’s next

In the future, I will likely look to do the following things.