Debian Router

A grayscale, dithered photo of a Traverse Ten64.

I've replaced my ISP's router with an open source, ARM64-based device from Traverse Technologies. It uses an NXP LS1088A, an 8-core variant of the CPU which was at one point offered as part of a module for the MNT Reform. While it can handle up to 32GB of DDR4 RAM, I've only got 16GB installed in mine. This is much, much more than a simple router needs; the device is designed to double as a home server, and I intend to eventually expand it into such.

This device's configuration files, as they deviate from the defaults, are reproduced here, but this write-up is still a work-in-progress. I'll expand on my explanations as I have time and inclination, as is generally the case in this digital garden of mine.

I've removed all sensitive information in the files below and replaced it with ALL CAPS placeholders rendered between angle brackets. Hopefully these placeholders are self-descriptive, but in case they aren't, I've provided a "placeholder legend" near the bottom of the page.

TODO

What it does

Packages

I am going off of my bash history and my memory here, so it's entirely possible I've missed something. Please send an email to my pseudonym at my domain if, in following my footsteps, you find I have.

$ sudo apt install netplan.io wireguard bind9 pppoeconf firewalld kea-dhcp4-server

Rationale

Why did I choose these packages?

Physical Interfaces

The first step in building a Linux router is turning on IPv4 forwarding:

net.ipv4.ip_forward=1

/etc/sysctl.conf

This next configuration was harder to arrive at than it should have been, primarily because I did not properly understand the difference between bridging and bonding, despite (likely) having learned about it at least once or twice before, and despite having certainly used bridging before when I build an OpenBSD-based router years ago. I blindly worked from the example given in Netplan's documentation which presents a device configured with multiple bonds as "acting as a router." I omitted the "mode" portion at first and found everything worked, but this was likely because the default bonding algorithm is "round robin," which means all connected devices were likely eventually getting the packets directed to them as they got retransmitted after the other end failed to receive an "ACK." Adding the "802.3ad" mode from the example later seemed to have no effect, likely for similar reasons.

To make matters worse, when I realized my mistake and decided to try switching to a bridge, I kept the bond-lan interface name in place just so I wouldn't have to change a bunch of config files for something I very well might have to change back immediately. This caused Bad Things to happen; systemd-networkd seemed to dislike my interface name, which resulted in the interface being established but not brought up. Changing the name from bond-lan to br1 fixed all my troubles there. I can only guess that I'd somehow fallen afoul of a cache somewhere.

I've got each of these physical ports set to optional in order to allow systemd-networkd to proceed with booting without stopping to wait for a link to be established on each of them, and I have dhcp4 and dhcp6 set to false on them because I don't want them reaching out to whatever's connected for IP addresses. They'll get their IP addresses statically assigned from the bridges defined lower down in the same file.

network:
    version: 2
    ethernets:
        eth0:
          dhcp4: no
          dhcp6: no
          optional: true
        eth1:
          dhcp4: no
          dhcp6: no
          optional: true
        eth2:
          dhcp4: no
          dhcp6: no
          optional: true
        eth3:
          dhcp4: no
          dhcp6: no
          optional: true
        eth4:
          dhcp4: no
          dhcp6: no
          optional: true
        eth5:
          dhcp4: no
          dhcp6: no
          optional: true
        eth6:
          dhcp4: no
          dhcp6: no
          optional: true
        eth7:
          dhcp4: no
          dhcp6: no
          optional: true
        eth8:
          dhcp4: no
          dhcp6: no
          optional: true
        eth9:
          dhcp4: no
          dhcp6: no
          optional: true
    bridges:
      br0:
        interfaces: [eth0, eth6, eth7, eth8, eth9]
        addresses: [<ROUTER STATIC CIDR IP>]
      br1:
        interfaces: [eth1, eth2, eth3, eth4, eth5]
        addresses: [192.168.0.1/24]

/etc/netplan/10-router.yaml

PPPoE

nameserver <ISP NAMESERVER 1>
nameserver <ISP NAMESERVER 2>

/etc/ppp/resolv.conf

# Secrets for authentication using CHAP
# client  server  secret      IP addresses
"<PPPOE USERNAME>"  *  "<PPPOE PASSWORD>"    *

/etc/ppp/chap-secrets

plugin rp-pppoe.so eth0.<ISP VLAN>
unit 1
linkname pppoe
user "<PPPOE USERNAME>"

noipdefault
usepeerdns
persist
maxfail 0

defaultroute
replacedefaultroute

hide-password
noauth

/etc/ppp/<ISP NAME>

[Unit]
Description=PPPoE connection
Documentation=man:pon(1) man:pppd(8) man:pppoeconf(8) man:interfaces(5)
BindsTo=sys-subsystem-net-devices-eth0.device
After=sys-subsystem-net-devices-eth0.device

[Service]
ExecStartPre=-/sbin/ip link add link eth0 name eth0.<ISP VLAN> type vlan id <ISP VLAN>
ExecStartPre=-/sbin/ip link set up dev eth0.<ISP VLAN>
ExecStartPre=-/sbin/ip addr add <ROUTER STATIC SUBNET> broadcast <ROUTER STATIC BROADCAST> dev eth0
ExecStartPre=-/sbin/ip route add default via <ROUTER STATIC IP> dev eth0
ExecStart=/usr/sbin/pppd call <ISP NAME>
Type=forking
SuccessExitStatus=5
PIDFile=/run/ppp-pppoe.pid

[Install]
WantedBy=sys-subsystem-net-devices-eth0.device

/etc/systemd/system/pppoe.service

Kea

{
"Dhcp4": {
    "interfaces-config": {
        "interfaces": ["br1/192.168.0.1", "br0/<ROUTER STATIC IP>"]
    },

    "lease-database": {
        "type": "memfile",
        "persist": true,
        "name": "/var/lib/kea/kea-leases4.csv",
        "lfc-interval": 3600
    },

    "renew-timer": 15840,
    "rebind-timer": 27720,
    "valid-lifetime": 31680,

    "option-data": [
        {
            "name": "domain-search",
            "data": "ten64.local"
        }
    ],

    "subnet4": [
        {
           "id": 1,
           "subnet": "192.168.0.1/24",
           "pools": [ { "pool": "192.168.0.2 - 192.168.0.254" } ],
           "option-data": [
               {
                  "name": "routers",
                   "data": "192.168.0.1"
               },
               {
                  "name": "domain-name-servers",
                  "data": "192.168.0.1"
               }
           ]
        },
        {
           "id": 2,
           "subnet": "<STATIC IP SUBNET>",
           "pools": [ { "pool": "<STATIC IP #2> - <STATIC IP #N>" } ],
           "option-data": [
               {
                   "name": "routers",
                   "data": "<ROUTER STATIC IP>"
               },
              {
                 "name": "broadcast-address",
                 "data": "<STATIC SUBNET BROADCAST IP>"
              },
              {
                 "name": "domain-name-servers",
                 "data": "<ROUTER STATIC IP>"
              }
           ],
           "reservations": [
             { // Home server
               "hw-address": "<HOME SERVER NIC MAC ADDRESS>",
               "ip-address": "<HOME SERVER STATIC IP>"
             }
           ]
        }
    ]
}
}

/etc/kea/kea-dhcp4.conf

SSH

With a bunch of comments removed:

Include /etc/ssh/sshd_config.d/*.conf

Port 20941 
AddressFamily inet
#ListenAddress 192.168.0.1
ListenAddress 10.22.20.1
#ListenAddress ::

PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile  .ssh/authorized_keys

PasswordAuthentication no
KbdInteractiveAuthentication no

UsePAM yes

X11Forwarding no
PrintMotd no

# Allow client to pass locale environment variables
AcceptEnv LANG LC_*

# override default of no subsystems
Subsystem  sftp  /usr/lib/openssh/sftp-server

ClientAliveInterval 120

/etc/ssh/sshd_config

Firewalld

For brevity, I've removed all the default values from firewalld.conf below and left in the only value I changed. I prefer dropping packets to rejecting them because I am old school, I guess; also, my network is simple enough that I don't really gain anything from seeing a packet was rejected rather than dropped.

...
DefaultZone=drop
...

/etc/firewalld/firewalld.conf

Not that I use the default zone at all, but it's always good to have the defaults you like in place, just in case you miss a specific case.

<?xml version="1.0" encoding="utf-8"?>
<zone target="DROP">
  <short>DMZ</short>
  <description>Pass-through ports</description>
  <source address="<ROUTER STATIC SUBNET>"/>

  <service name="mdns"/>
  <service name="dns"/>

  <protocol value="icmp"/>
  <protocol value="ipv6-icmp"/>

  <port port="32051" protocol="udp"/>
  <port port="63610" protocol="udp"/>

  <rule family="ipv4">
    <source address="192.168.0.1/24"/>
    <masquerade/>
  </rule>

  <rule family="ipv4">
    <source address="10.22.20.0/24"/>
    <masquerade/>
  </rule>

  <interface name="br0"/>
  <forward/>
</zone>

/etc/firewalld/zones/dmz.xml

<?xml version="1.0" encoding="utf-8"?>
<zone target="DROP">
  <short>Internet</short>
  <description>A Series of Tubes...</description>
  <interface name="eth0"/>
  <interface name="eth0.<ISP VLAN>"/>
  <interface name="ppp1"/>
  <rule family="ipv4">
    <destination address="<ROUTER STATIC IP>"/>
    <port port="63610" protocol="udp"/>
    <accept/>
  </rule>
</zone>

/etc/firewalld/zones/inet.xml

<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>LAN</short>
  <description>Local Area Network</description>
  <source address="192.168.0.1/24"/>
  <service name="mdns"/>
  <service name="dns"/>
  <service name="dhcp"/>
  <port port="63610" protocol="udp"/>
  <protocol value="icmp"/>
  <protocol value="ipv6-icmp"/>
  <interface name="br1"/>
  <forward/>
</zone>

/etc/firewalld/zones/lan.xml

<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>LAN</short>
  <description>Local Area Network</description>
  <source address="192.168.0.1/24"/>
  <service name="mdns"/>
  <service name="dns"/>
  <service name="dhcp"/>
  <port port="63610" protocol="udp"/>
  <protocol value="icmp"/>
  <protocol value="ipv6-icmp"/>
  <interface name="br1"/>
  <forward/>
</zone>
root@ten64:/etc/firewalld# cat zones/wireguard.xml 
<?xml version="1.0" encoding="utf-8"?>
<zone target="DROP">
  <short>wg0 zone</short>
  <description>Router VPN Wireguard network</description>
  <source address="10.22.20.0/24"/>

  <service name="dns"/>
  <service name="mdns"/>

  <protocol value="icmp"/>
  <protocol value="ipv6-icmp"/>

  <!-- SSH access for specified addresses -->
  <rule family="ipv4">
    <destination address="10.22.20.1"/>
    <port port="20941" protocol="tcp"/>
    <source ipset="wg0-admin-ipv4"/>
    <accept/>
  </rule>

  <!-- Interdevice communication for specified addresses -->
  <rule family="ipv4">
    <destination ipset="wg0-convivial-ipv4"/>
    <source ipset="wg0-convivial-ipv4"/>
    <accept/>
  </rule>
  <interface name="wg0"/>
  <forward/>
</zone>

/etc/firewalld/zones/wireguard.xml

<?xml version="1.0" encoding="utf-8"?>
<ipset type="hash:ip">
  <short>IPv4s with SSH access</short>
  <option name="family" value="inet"/>
  <entry>10.22.20.2</entry>
</ipset>

/etc/firewalld/ipsets/wg0-admin-ipv4.xml

<?xml version="1.0" encoding="utf-8"?>
<ipset type="hash:ip">
  <short>IPv4s allowed to communicate with each other</short>
  <option name="family" value="inet"/>
  <entry>10.22.20.2</entry>
  <entry>10.22.20.3</entry>
</ipset>

/etc/firewalld/ipsets/wg0-convivial-ipv4.xml

<?xml version="1.0" encoding="utf-8"?>
<ipset type="hash:ip">
  <short>IPv4s allowed to masquerade as the router</short>
  <option name="family" value="inet"/>
  <entry>10.22.20.2</entry>
  <entry>10.22.20.3</entry>
</ipset>

/etc/firewalld/ipsets/wg0-vpn-ipv4.xml

<?xml version="1.0" encoding="utf-8"?>
<policy target="ACCEPT">
  <ingress-zone name="lan"/>
  <ingress-zone name="dmz"/>
  <ingress-zone name="wireguard"/>
  <egress-zone name="inet"/>

  <rule>
    <tcp-mss-clamp/>
  </rule>

  <rule family="ipv4">
    <source address="192.168.0.0/24"/>
    <masquerade/>
  </rule>

  <rule family="ipv4">
    <source ipset="wg0-vpn-ipv4"/>
    <masquerade/>
  </rule>
</policy>

/etc/firewalld/policies/internet-access.xml

<?xml version="1.0" encoding="utf-8"?>
<policy target="DROP">
  <egress-zone name="lan"/>
  <ingress-zone name="lan"/>
  <egress-zone name="dmz"/>
  <ingress-zone name="dmz"/>
  <egress-zone name="wireguard"/>
  <ingress-zone name="wireguard"/>

  <!-- Expose Wireguard on the home server, globally -->
  <rule family="ipv4">
    <destination address="<HOME SERVER STATIC IP>"/>
    <port port="32051" protocol="udp"/>
    <accept/>
  </rule>
  
  <rule family="ipv4">
   <source address="192.168.0.0./24"/>
   <accept/>
  </rule>

  <rule family="ipv4">
   <source address="10.22.20.0/24"/>
   <accept/>
  </rule>
</policy>

/etc/firewalld/policies/interzone-access.xml

<?xml version="1.0" encoding="utf-8"?>
<policy target="DROP">
  <ingress-zone name="wireguard"/>
  <egress-zone name="lan"/>
  <source ipset="wg0-vpn-ipv4"/>
</policy>

/etc/firewalld/policies/wireguard-lan.xml

Wireguard

network:
  version: 2
  tunnels:
    wg0:
      mode: wireguard
      addresses: [10.22.20.1/24]
      port: 63610
      key:
        private: <ROUTER WIREGUARD PRIVATE KEY>
      peers:
        - allowed-ips: [10.22.20.2/32]
          keys:
            public: <LAPTOP WIREGUARD PUBLIC KEY>
        - allowed-ips: [10.22.20.3/32]
          keys:
            public: <PHONE WIREGUARD PUBLIC KEY>

/etc/netplan/20-wireguard.yaml

Bind

For DNS-based ad blocking at the router I considered using PiHole, but this felt too opaque. Instead, I did something similar to what I'd been doing on the OpenBSD router and set up a script to pull a blocklist from the web, following the instructions at https://github.com/mueller-ma/block-ads-via-dns. I made a few changes to the script there, most notably to switch to RPZ-based blocking, as I'd observed absurdly high memory usage when defining a zone for each blocked domain, as the stock setup does at the moment I'm writing this.

include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
include "/etc/bind/named.conf.root-hints";

/etc/bind/named.conf, unaltered from its default Debian 13 contents.

acl recursers { localhost; localnets; };

zone "rpz" {
  type master;
  file "/etc/bind/rpz.db";
};

options {
	directory "/var/cache/bind";
	response-policy { zone "rpz"; };

	forwarders {
    <ISP NAMESERVER 1>;
    <ISP NAMESERVER 2>;
		8.8.8.8;
		9.9.9.9;
	};

	recursion yes;
	allow-recursion { recursers; };

	//========================================================================
	// If BIND logs error messages about the root key being expired,
	// you will need to update your keys.  See https://www.isc.org/bind-keys
	//========================================================================
	dnssec-validation auto;

	auth-nxdomain no;
	listen-on { 192.168.0.1; };
	listen-on { <ROUTER STATIC IP>; };
	listen-on { 10.22.20.1; };
	listen-on-v6 { none; };
	check-names master ignore;
	check-names slave ignore;
	check-names response ignore;
};

/etc/bind/named.conf.options

[Unit]
Description=Logs system statistics to the systemd journal
Wants=block-ads-via-dns.timer

[Service]
Type=oneshot
ExecStart=/root/generate-zonefile.sh

[Install]
WantedBy=multi-user.target

/etc/systemd/system/block-ads-via-dns.service

[Unit]
Description=Refresh the ad blocking DNS zone files
Requires=block-ads-via-dns.service

[Timer]
Unit=block-ads-via-dns.service
OnCalendar=*-*-* 07:00:00

[Install]
WantedBy=timers.target

/etc/systemd/system/block-ads-via-dns.timer

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# display date
date

# Set tempfiles
# All_domains will contain all domains from all lists, but also duplicates and ones which are whitelisted
all_domains=$(mktemp)
# Like above, but no duplicates or whitelisted URLs
all_domains_uniq=$(mktemp)
# We don't write directly to the zonefile. Instead to this temp file and copy it to the right directory afterwards
zonefile=$(mktemp)
zfpreface=$(mktemp)

# Define local black and white lists
# Uncomment if you have no local files
manual_blacklist="/root/blacklist"
manual_whitelist="/root/whitelist"

# StevenBlack GitHub Hosts
# Uncomment ONE line containing the filter you want to apply
# See https://github.com/StevenBlack/hosts for more combinations
#wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews/hosts
#wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/gambling/hosts
#wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts
#wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social/hosts
#wget -q -O StevenBlack-hosts https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts

# Filter out localhost and broadcast
cat StevenBlack-hosts | grep '^0.0.0.0' | egrep -v '127.0.0.1|255.255.255.255|::1' | cut -d " " -f 2 >> $all_domains

# Add local blacklist
if [ -f $manual_blacklist ]
then
    cat $manual_blacklist >> $all_domains
fi

# Filter out comments and empty lines
cat $all_domains | egrep -v '^$|#' | sort | uniq  > $all_domains_uniq

# Apply local whitelist
if [ -f $manual_whitelist ]
then
    for i in $(cat $manual_whitelist)
    do
        #echo i $i
        sed --in-place= "/$i/d" $all_domains_uniq
    done
fi

# Populate RPZ database with blocked domains
cat $all_domains_uniq | sed -r 's/(.*)/\1 CNAME ./' > $zonefile # Using RPZ instead of memory-intensive original method.

# Generate RPZ database preface
cat <<EOF > $zfpreface
; Auto-generated from /root/generate-zonefile.sh
; Don't edit manually, you'll lose your changes.
\$TTL 24h
@       IN      SOA     server.<HOSTNAME>. hostmaster.<HOSTNAME>. (
                $(date +%Y%m%d%H)          ; Serial
                     86400          ; Refresh
                      3600          ; Retry
                   2419200          ; Expire
                     10800 )        ; Negative Cache TTL

@       IN      NS   server.<HOSTNAME>.
EOF

cat $zfpreface $zonefile > /etc/bind/rpz.db

# Remove all tempfiles
rm $all_domains $all_domains_uniq $zonefile $zfpreface StevenBlack-hosts

# Restart bind
systemctl restart named

# For logfile
echo -e 'done\n\n'

/root/generate-zonefile.sh

Placeholder Legend

Resources

Page Created: 2025-08-02

Last Updated: 2026-01-19

Last Reviewed: 2025-08-03