Building a Router from Scratch with Debian, Part 1

2024-05-30

21 min read

hero image
Goodbye, Verizon router

I used to use a Verizon FIOS CR1000A router. It was pretty good: easy to set up, fast, with a 10GbE uplink, three 2.5GbE LAN ports, and Wi-Fi 6E support. I've been using it for the 18 months since I moved to the States. However, as I dove into my homelab journey again, I soon found it to be the bottleneck of what I could achieve.

Why Replace the Router?

First, the DNS server settings were restrictive. The router refused to advertise a custom DNS server and insisted on promoting itself in its DHCP messages. While it had an "upstream DNS server" setting, it acted as a proxy DNS server, consulting my Pi-hole server upon a cache miss. Why couldn't it just point directly to my Pi-hole server? Although the impact was minimal, I wanted that flexibility. In the end, I disabled its DHCP functionality altogether and used Pi-hole as the DHCP server as a workaround.

Second, the IPv6 support was lacking. The router only assigned one IPv6 /64 prefix to the network. Even with guest and IoT networks enabled, it could only expose three prefixes, but those networks were too limited to be practical. It couldn't configure Unique Local Address (ULA) prefixes either. If I wanted to run dual-stack services in my apartment, I'd need to update all the IPv6 DNS records and the IPv6 DNS server advertisement (RDNSS) whenever Verizon changeed my prefix. Things like DDNS have been widely used, and my friend also suggested me to use mDNS (multicast DNS) as a workaround, but they felt... imperfect. I didn't want to compromise.

Wait, did I mention setting RDNSS? Well, in fact the router didn't even let me set the RDNSS field. I got once more some "upstream DNS server" settings, and upon pointing them to the link-local address of my Pi-hole server it didn't work. This was frustrating.

The last missing feature (for now) is the VLAN support. I don't blame Verizon for not having this feature, as it would require a structural change in their management panel, and by the end of the day the average user might only get confused by all the settings. Verizon does support two VLANs: the guest and IoT subnets, but there is simply too much hardcoding to turn them into anything useful. To make things worse, the guest network has no isolation from the main subnet. (https://community.verizon.com/t5/Fios-Internet-and-High-Speed/IOT-Network-Segregation-from-Primary-Network/m-p/1715794) What is the point of having a guest network then?

Building My Own Router

Yeah, so why not just change your router? Yes, I'm about to change it. But instead of buying a new off-the-shelf solution or using a mature router OS like Opnsense or Mikrotik, I decided to build my own router from scratch using Linux. Originally, I considered Opnsense, but learning it was based on BSD instead of Linux was a dealbreaker for me since I can't live without Docker. BSD has its own way of isolation and orchestration, but who's got time for that?! I'd rather spend a whole lot more time to build the router myself 🤡👍.

Creating a router from a fresh install of Linux is not as hard as you might imagine. The Linux network stack handles most of the heavy lifting. Set up interfaces, create iptables rules, and the routing and firewalling are done. Add some necessary services: DNS, DHCP for IPv4, and Radvd for IPv6, and you've got a router up and running. That said, I encountered numerous problems along the way, which I'll share here to save my fellow router DIYers the time of troubleshooting.

I chose Debian 12 because Debian and Ubuntu are the distros I'm most familiar with. Debian manages all the network configurations via the file /etc/network/interfaces (the Debian way), which is a bless because NetworkManager is really a PITA from my experience. I wrote the configurations as Ansible playbooks so that I can recreate it when I accidentally nuke the router or when the hardware fails. I am also sharing the playbooks as a GitHub repo so that anyone interested can set up their own router.

This guide consists of two parts. In this first part, we'll set up a working router. The second part will cover VLAN configurations and a weird bug related to Address Resolution Protocol (ARP).

Ansible Playbooks

I am posting all the configurations to GitHub as Ansible playbooks, so that one with proper hardware can configure that in one command.

Link to the GitHub repo

Requirements

I'm not a networking expert, but if you understand common network protocols like DNS, DHCP, and router advertisement, you should follow along fine.

Here's what you'll need:

  • A homelabber who doesn't hate troubleshooting
  • A regular PC with 2 or more ethernet cards*
  • (Optional but helpful) A friend specialized in Computer Networking who's built a router before

I'm fortunate to have such a friend who helps me a lot, but I assume you should not encounter problems if you follow this guide or run the playbook. I also appreciate the help I received from the people on Reddit and serverfault.

Notes on the PC Requirement

By "regular PC," I mean a generic PC you can use as a server or daily driver. I use a mini PC with six 2.5GbE ethernet ports because I prefer wired connections, but it can work with just two ports (one for WAN, one for LAN). Don't try this on a home router due to its special architecture.

I separate WAN and LAN by creating a bridge on all the LAN ports and setting up routing and firewall rules between them. Home routers work differently. They usually gave only one network interface connecting to a switch with several WAN and LAN ports. They have to use two VLANs to segregate LAN and WAN becuase they only have one interface. We can of course use VLANs for our DIY router, but I am too invested to my way to change.

home router internal arch
Source: https://openwrt.org/_detail/media/linksys/wrt54/wrt54g-v2.v3_wrt54gs-v1.v2_internal-arch.png?id=toh%3Alinksys%3Awrt54g

Configure a Bridge LAN Interface

First, fresh install Debian 12 on the machine. I recommend not adding any packages or adding only the standard system utilities package when choosing components. After installation, run ip addr to see all the network interfaces available. My machine has 6 ports, ranging from enp1s0 to enp7s0 but skipping enp6s0. I chose enp1s0 as the WAN port and the rest as LAN ports.

To manage all the LAN ports uniformly, create a network bridge br0. A bridge acts as a layer 2 switch between the ports under it. All ports under the bridge can communicate with each other with zero configuration. When forwarding packets, the source interface shows as br0 instead of the specific physical port it came from. This greatly simplifies packet forwarding rules, since I don't need to write 5 rules for each LAN-WAN port pair, plus the 25 interconnection rules between any two ports.

You can configure a bridge using brctl or the more recent bridge command developed by the same author of brctl that comes from the iproute2 collection. To manage everything with a single file, I chose the Debian way of configuring it with /etc/network/interfaces (which utilizes brctl under the hood). According to the Debian wiki bridge-utils needs to be installed for the bridge to function correctly. If a bridge is configured but bridge-utils is not installed, upon restarting networking the system will get stuck, and will waitinfinitely for the networking service to load when rebooting, effectively bricking the machine.

$ sudo apt install bridge-utils

Add the following to /etc/network/interfaces:

# /etc/network/interfaces

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface (WAN)
allow-hotplug enp1s0
iface enp1s0 inet dhcp
iface enp1s0 inet6 auto

# Define bridge interface
auto br0
iface br0 inet static
  bridge_ports enp2s0 enp3s0 enp4s0 enp5s0 enp7s0
  address 192.168.0.1
  netmask 255.255.255.0

# Bring up bridge ports with the bridge
  up ip link set enp2s0 up
  up ip link set enp3s0 up
  up ip link set enp4s0 up
  up ip link set enp5s0 up
  up ip link set enp7s0 up

# Bring bridge interface up
  up ip link set br0 up

# IPv6 GUA (managed by dhclient)
iface br0 inet6 manual

# IPv6 ULA
iface br0 inet6 static
  address fd00::1
  netmask 64

I masked the ULA address to fd00::1 for privacy. In reality it's a randomly generated /64 prefix under fd00::/8. Always generate a random subnet instead of using an arbitrary ULA prefix like fd00::/64.

The WAN port is set to use upstream DHCP and SLAAC because that's how Verizon FIOS works. The LAN interface has a 192.168.0.1/24 IPv4 subnet and two IPv6 addresses: a Global Unicast Address (GUA), which is the usual address we see in IPv6 configuration, and a Unique Local Address (ULA), similar to the private addresses in IPv4. The ULA is often criticized because people configure it, terminate GUA at the gateway, and use a NAT between them, just as we have been doing in IPv4. However, ULA should be assigned along with, not instead of, a GUA, unless you have special needs. Unlike IPv4, IPv6 allows multiple addresses per interface, so we can have the best of both worlds by having both a GUA as usual plus a ULA for internal communication. The interface will decide which interface to use when sending packets. No NAT, the evil IPv6 has been devoted to eliminate, is involved in this process.

After restarting the networking service, the LAN bridge should be up and running. The IPv6 GUA isn't present yet since we haven't configured it yet, but we'll get to it.

2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
    inet 98.109.32.213/24 brd 98.109.32.255 scope global dynamic enp1s0
       valid_lft 4550sec preferred_lft 4550sec
    inet6 fe80::xxxx:xxff:fexx:xxxx/64 scope link
       valid_lft forever preferred_lft forever
8: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.1/24 brd 192.168.0.255 scope global br0
       valid_lft forever preferred_lft forever
    inet6 fd00::1/64 scope global
       valid_lft forever preferred_lft forever
    inet6 fe80::xxxx:xxff:fexx:xxxx/64 scope link
       valid_lft forever preferred_lft forever

Note that the WAN interface doesn't have a GUA. It forwards packets using only the link-local address. This is something I will never discover if I keep using any commercial routers, because all they will show on their admin portals will be the GUA.

Set Up Essential Services

Next, we'll configure DNS, DHCP, and router advertisement. These essential services allow devices to configure themselves correctly to communicate with the public Internet. I used isc-dhcp-server as the DHCP server, Pi-hole as the DNS server (since I'd been running it for a while), radvd for the router advertisement, and wide-dhcpv6-client to acquire an IPv6 GUA prefix from Verizon.

DNS Server

I'll leave the DNS server setup to the official Pi-hole guide. I'm just running Pi-hole in a Docker container. After setting up Pi-hole, remember to set the DNS server of the router itself to localhost:

# /etc/resolv.conf

nameserver 127.0.0.1
nameserver ::1

DHCP Server

First, set up the DHCP server because I want to make sure IPv4 functions as soon as possible (my partner was losing patience as I struggled with the bridge setup).

Install the isc-dhcp-server package:

$ sudo apt install isc-dhcp-server

There are two files that needs configuring. First, at the end of the file /etc/default/isc-dhcp-server, add the interfaces we want to broadcast DHCP messages:

# On what interfaces should the DHCP server (dhcpd) serve DHCP requests?
# Separate multiple interfaces with spaces, e.g. "eth0 eth1".
INTERFACESv4="br0"
INTERFACESv6="" 

I don't set v6 interfaces because I want the devices to use SLAAC instead of DHCPv6.

Then, add the following configurations to /etc/dhcp/dhcpd.conf. It looks pretty much like what you will encounter in the DHCP settings of a typical router OS.

subnet 192.168.0.0 netmask 255.255.255.0 {
    range 192.168.0.2 192.168.0.254;
    option routers 192.168.0.1;
    option subnet-mask 255.255.255.0;
    option domain-name-servers 192.168.0.1;
}

Static DHCP leases can also be added to this file. Now restart the DHCP server:

$ sudo systemctl restart isc-dhcp-server

DHCP Client

The DHCP client (dhclient) gets a WAN IP and an upstream gateway from the ISP. It works out of the box, but with one caveat: every now and then the dhclient receives a new DHCP message from the ISP, and it will overwrite the DNS settings of the router (remember we set it to localhost in the DNS section). To avoid this behavior, we need to add a hook to overwrite the DNS update function. Create a new file /etc/dhcp/dhclient-enter-hooks.d/leave_my_resolv_conf_alone with the following content:

make_resolv_conf() { :; }

DHCPv6 Client

Cool, now that the IPv4 part is fully functional, let's focus on IPv6 functionalities. Much like IPv4, we need a client service that fetches the prefix from upstream and a server service that broadcasts the prefixes.

There are multiple DHCPv6 clients, most notably the WIDE-DHCPv6 client, and the ISC DHCP client (which ships with Debian and which we are using for DHCPv4). Unfortunately I couldn't make the latter work after hours of trying, but you can follow this wiki and see if you can make it. I had to go to the painless WIDE client instead.

$ sudo apt install wide-dhcpv6-client

In /etc/default/wide-dhcpv6-client, add the WAN interface:

# Interfaces on which the client should send DHCPv6 requests and listen to
# answers. If empty, the client is deactivated.
INTERFACES="enp1s0"

Then replace the content of /etc/wide-dhcpv6/dhcp6c.conf:

# Default dhpc6c configuration: it assumes the address is autoconfigured using
# router advertisements.

profile default
{
  information-only;

  request domain-name-servers;
  request domain-name;

  script "/etc/wide-dhcpv6/dhcp6c-script";
};

interface enp1s0 {
  send ia-pd 0;
};

id-assoc pd 0 {
  prefix-interface br0 {
    sla-id 1;
    sla-len 8;
  };
};

The configuration might look confusing. What it does is asking for a prefix and store that in slot 0 (send ia-pd 0). Then it associates slot 0 with interface br0, assigns the first subnet (sla-id 1) to the interface. The subnet is 8 bits smaller than the prefix it acquires (sla-len 8) because Verizon is assigning /56 to the routers and I need a /64. We configured the GUA to manual at the beginning, and this is where that address update happens.

Lastly, we still need to edit the file /etc/wide-dhcpv6/dhcp6c-script and comment out most of it. The reason is the same as above: we don't want Verizon to influence our DNS server settings.

#!/bin/sh
# dhcp6c-script for Debian/Ubuntu. Jérémie Corbier, April, 2006.
# resolvconf support by Mattias Guns, May 2006.


[ -f /etc/default/wide-dhcpv6-client ] && . /etc/default/wide-dhcpv6-client

### Commented out because I don't need it to mess up with my resolv.conf

# RESOLVCONF="/sbin/resolvconf"

#if [ -n "$new_domain_name" -o -n "$new_domain_name_servers" ]; then
#    old_resolv_conf=/etc/resolv.conf
#    new_resolv_conf=/etc/resolv.conf.dhcp6c-new
#    rm -f $new_resolv_conf
#    if [ -n "$new_domain_name" ]; then
#        echo search $new_domain_name >> $new_resolv_conf
#    fi
#    if [ -n "$new_domain_name_servers" ]; then
#        for nameserver in $new_domain_name_servers; do
#            # No need to add an already existing nameserver
#            res=$(grep "nameserver $nameserver" $old_resolv_conf)
#            if [ -z "$res" ]; then
#                echo nameserver $nameserver >> $new_resolv_conf
#            fi
#        done
#    fi

#    # Use resolvconf if available
#    if [ -h "$old_resolv_conf" -a -x "$RESOLVCONF" ]; then
#        for IFACE in $INTERFACES; do
#            cat $new_resolv_conf | $RESOLVCONF -a $IFACE
#        done
#    else
#        # To preserve IPv4 informations...
#        cat $old_resolv_conf >> $new_resolv_conf
#        chown --reference=$old_resolv_conf $new_resolv_conf
#        chmod --reference=$old_resolv_conf $new_resolv_conf
#        mv -f $new_resolv_conf $old_resolv_conf
#    fi
#fi


exit 0

Restart the service:

sudo systemctl restart wide-dhcpv6-client

We can't see the GUA in ip addr output yet (though it will log the prefix if you set verbose mode on), because we only have the prefix but not the addresses. Now let's configure the router advertisement.

Router Advertisement Daemon (radvd)

Router Advertisement (RA) sends prefixes and DNS messages to devices. After receiving the prefixes the devices can configure their own IPv6 addresses under the subnets.

Install radvd and configure /etc/radvd.conf:

$ sudo apt install radvd
interface br0
{
    AdvSendAdvert on;

    # GUA Prefix, dynamically updated by dhcp6c
    prefix ::/64 {
        AdvOnLink on;
        AdvAutonomous on;
        AdvRouterAddr on;
    };

    # ULA Prefix
    prefix fd00::/64
    {
        AdvOnLink on;
        AdvAutonomous on;
        AdvRouterAddr on;
    };

    RDNSS fd00::1
    {
    };
};

We broadcast the GUA prefix (which will be propagated by the DHCPv6 client) and the ULA prefix at the same time. We can add more prefixes if we like, but one ULA is enough in my use case.

Restart the service now to load the configuration.

$ sudo systemctl restart radvd

Now if you type ip addr, you should see the GUA on the br0 interface.

8: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.1/24 brd 192.168.0.255 scope global br0
       valid_lft forever preferred_lft forever
    inet6 2600:4041:449d:1801:xxxx:xxff:fexx:xxxx/64 scope global
       valid_lft forever preferred_lft forever
    inet6 fd00::1/64 scope global
       valid_lft forever preferred_lft forever
    inet6 fe80::xxxx:xxff:fexx:xxxx/64 scope link
       valid_lft forever preferred_lft forever

Enable Routing

Even after all these complicated settings, the router still can't do the work it's supposed to do: routing.

Enabling the packet forwarding ability is actually quite easy. The Linux kernel is quite capable of doing it; all we need is set some kernel flags.

But hold on a second. If we enable packet forwarding now, our network will be exposed to the Internet with zero protection. And we haven't configured NAT for IPv4 traffic, either. The v4 traffic won't work because the packets cannot reach back to our 192.168.0.0/24 subnet. Let's first set up iptables rules for NAT and firewalling.

Here are the final rules without much explanation, as iptables itself deserves a full tutorial, and others have done a far better job at teching it. Nevertheless the rules are rather self-explanatory.

#!/usr/bin/env bash

################## Constants ##################

wan=enp1s0
main_lan=br0
home_ipv4=192.168.0.0/24
home_ula=fd00::/64


################## Default policies ##################

# forward DROP, input ACCEPT, output ACCEPT
# If set input to DROP, docker containers will stop working

iptables -P FORWARD DROP
ip6tables -P FORWARD DROP


################# DHCP & iCMP rules ##################

# Allow DHCP & DHCPv6
iptables -A INPUT -i ${wan} -p udp --dport 68 --sport 67 -j ACCEPT
ip6tables -A INPUT -i ${wan} -p udp --dport 546 --sport 547 -j ACCEPT

# Allow ICMP Echo-Request (Ping)
iptables -A INPUT -p icmp --icmp-type echo-request -i ${wan} -j ACCEPT
iptables -A INPUT -p icmp --icmp-type echo-reply -i ${wan} -j ACCEPT
# Essential for proper path MTU discovery
iptables -A INPUT -p icmp --icmp-type fragmentation-needed -j ACCEPT
# Other useful messages:
iptables -A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
iptables -A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT


################## ICMPv6 rules ###################

# Conform to RFC 4890

# FORWARD chain rules

# Traffic That Must Not Be Dropped
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT  # Destination Unreachable (Type 1) - All codes
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type packet-too-big -j ACCEPT  # Packet Too Big (Type 2)
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type 3/0 -j ACCEPT  # Time Exceeded (Type 3) - Code 0
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type 4/1 -j ACCEPT  # Parameter Problem (Type 4) - Code 1
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type 4/2 -j ACCEPT  # Parameter Problem (Type 4) - Code 2

# Connectivity checking messages
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type echo-request -j ACCEPT  # Echo Request (Type 128)
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type echo-reply -j ACCEPT  # Echo Response (Type 129)

# Traffic That Normally Should Not Be Dropped
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type 3/1 -j ACCEPT  # Time Exceeded (Type 3) - Code 1
ip6tables -A FORWARD -p ipv6-icmp --icmpv6-type 4/0 -j ACCEPT  # Parameter Problem (Type 4) - Code 0

# INPUT chain rules

# Traffic That Must Not Be Dropped
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT  # Destination Unreachable (Type 1) - All codes
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type packet-too-big -j ACCEPT  # Packet Too Big (Type 2)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 3/0 -j ACCEPT  # Time Exceeded (Type 3) - Code 0
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 4/1 -j ACCEPT  # Parameter Problem (Type 4) - Code 1
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 4/2 -j ACCEPT  # Parameter Problem (Type 4) - Code 2

# Connectivity checking messages
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type echo-request -j ACCEPT  # Echo Request (Type 128)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type echo-reply -j ACCEPT  # Echo Response (Type 129)

# Address Configuration and Router Selection messages
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type router-solicitation -j ACCEPT  # Router Solicitation (Type 133)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type router-advertisement -j ACCEPT  # Router Advertisement (Type 134)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type neighbor-solicitation -j ACCEPT  # Neighbor Solicitation (Type 135)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type neighbor-advertisement -j ACCEPT  # Neighbor Advertisement (Type 136)

# Link-Local Multicast Receiver Notification messages
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 130 -j ACCEPT  # Listener Query (Type 130)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 131 -j ACCEPT  # Listener Report (Type 131)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 132 -j ACCEPT  # Listener Done (Type 132)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 143 -j ACCEPT  # Listener Report v2 (Type 143)

# SEND Certificate Path Notification messages
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 148 -j ACCEPT  # Certificate Path Solicitation (Type 148)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 149 -j ACCEPT  # Certificate Path Advertisement (Type 149)

# Multicast Router Discovery messages
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 151 -j ACCEPT  # Multicast Router Advertisement (Type 151)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 152 -j ACCEPT  # Multicast Router Solicitation (Type 152)
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 153 -j ACCEPT  # Multicast Router Termination (Type 153)

# Traffic That Normally Should Not Be Dropped
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 3/1 -j ACCEPT  # Time Exceeded (Type 3) - Code 1
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type 4/0 -j ACCEPT  # Parameter Problem (Type 4) - Code 0


################## Forward rules ##################

# NAT for outgoing v4 traffic
iptables -t nat -A POSTROUTING -o ${wan} -s ${home_ipv4} -j MASQUERADE

# Allow within main LAN
iptables -A FORWARD -i ${main_lan} -o ${main_lan} -j ACCEPT
ip6tables -A FORWARD -i ${main_lan} -o ${main_lan} -j ACCEPT

# Allow main LAN to WAN
iptables -A FORWARD -i ${main_lan} -o ${wan} -j ACCEPT
ip6tables -A FORWARD -i ${main_lan} -o ${wan} -j ACCEPT

# Only allow WAN to LAN established traffic
iptables -A FORWARD -i ${wan} -o ${main_lan} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ip6tables -A FORWARD -i ${wan} -o ${main_lan} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT


################## Input rules ##################

# Only allow established connections from WAN
iptables -A INPUT -i ${wan} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A INPUT -i ${wan} -j DROP
ip6tables -A INPUT -i ${wan} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ip6tables -A INPUT -i ${wan} -j DROP

# Accept all incoming traffic on LAN interface
iptables -A INPUT -i ${main_lan} -j ACCEPT
ip6tables -A INPUT -i ${main_lan} -j ACCEPT

I am setting default INPUT policy to ACCEPT instead of DROP because I found too many exceptions should be made if I do it the other way around, and I am better off just leaving it ACCEPT and blocking all WAN incoming packets.

Update 06/01/2024: redo the ICMP and ICMPv6 rules to conform to RFC 4890. Previously I just asked ChatGPT to generate a bunch of ICMPv6 rules and used them as-is.

Save this script somewhere and configure a systemd service that executes it on startup. For example, here's the service template I use:

[Unit]
After=network.target

[Service]
ExecStart=/usr/local/bin/iptables-rules.sh  # Path to the iptables rules script

[Install]
WantedBy=default.target

Finally, enable packet forwarding. Edit /etc/sysctl.conf and add:

# Enable IPv4 packet forwarding
net.ipv4.ip_forward=1
# Enable IPv6 packet forwarding
net.ipv6.conf.all.forwarding=1

# Configure ARP settings. These settings will be explained in part 2. For now, just go ahead and set them.
net.ipv4.conf.all.arp_filter=1
net.ipv4.conf.all.arp_announce=1
net.ipv4.conf.all.arp_ignore=1

And reload the settings.

$ sudo sysctl -p

Boom! The router should now be fully functional! Aside from lacking a fancy UI, it offers far better performance and more flexibility than your ISP router.

Conclusion

And there you have it! A fully functioning router built from scratch using Debian 12. In the next part, I'll cover VLAN configurations and address a strange bug related to Address Resolution Protocol (ARP). Stay tuned!