SOHO Router Setup Guide

Introduction

This is a small guide on how to setup a router.

Problem statement

Everyone uses some method to route internet traffic in their homes, businesses, and labs. However, given the current state of the world, we face challenges such as backdoors, poor security solutions, aggressive marketing, and overpriced products. These risks leave us with no choice but to take control of our own networking.

In this guide, we will configure our own router while keeping these challenges in mind.

Requirements

Our setup must meet the following criteria:

  • Minimal attack surface
  • Simple maintenance
  • No unnecessary bloat
  • Basic security enhancements
  • Wi-Fi support
  • IPv6 NAT

Overall strategy

We will not use existing solutions like OpenWrt or pfSense, as they contain too many components and have a larger attack surface.

For hardware, I chose the ZimaBoard because it has two Ethernet ports and AES-NI instructions, which may be useful for a future VPN setup. A Raspberry Pi could serve the same purpose, but it would require a USB Ethernet adapter.

Linux & Packages

We need to choose and install a Linux distribution. My personal preference is Void Linux with musl and runit.

  • Runit is a simple and lightweight replacement for systemd.
  • Musl is recommended for embedded systems due to its cleaner and simpler codebase.

Any other Linux distribution should work fine, especially if you already have a preferred one.

Packages that we’re going to be using:

  • nftables - Firewall
  • openssh - SSH server
  • dhcpcd - DHCP client (to obtain an IP from the ISP)
  • dnsmasq - DHCP service for the local network and Wi-Fi
  • hostapd - Wi-Fi access point
  • dnscrypt-proxy - Secure DNS
  • chrony - Time synchronization service

Optional packages:

  • lighttpd - HTTP for vnstat charts
  • vnstat - Traffic usage monitor

Useful Networking Tools:

  • iperf3
  • nload
  • nmap
  • tcpdump
  • tcptrack
  • wavemon
  • traceroute
  • netcat
  • ifstatus
  • iptraf-ng
  • iftop
  • iputils
  • iw
  • sysstat

Wi-Fi Drivers (if needed):

  • wifi-firmware
  • wireless-regdb

Configuration files

Don’t forget to adjust interface names and IP nets

Static IPs for local net `rc.local`

File /etc/rc.local

#!/bin/sh

# Local net
ip link set dev enp3s0 up
ip addr add 192.168.190.1/24 brd + dev enp3s0
ip addr add fd11:1:1::1/64 dev enp3s0
ip link set enp3s0 mtu 9192

# ISP WAN
ifconfig enp2s0 mtu 9192

# Wi-Fi AP
ip addr add 192.168.191.1/24 dev wlp0s21f0u1
ip addr add fd11:1:2::1/64 dev wlp0s21f0u1
Kernel configuration and tcp ip forwarding `sysctl.conf`

File /etc/sysctl.conf

# Do not accept ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.secure_redirects = 0

# Do not accept source route
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0

net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1

# Reverse path filter
# prevent some spoofing attacks
#net.ipv4.conf.all.rp_filter = 1
#net.ipv4.conf.default.rp_filter = 1

# Log Martian Packets
#net.ipv4.conf.all.log_martians = 1
#net.ipv4.conf.default.log_martians = 1

# Ignore IPv6 RA
#net.ipv6.conf.all.accept_ra = 0
#net.ipv6.conf.default.accept_ra = 0

kernel.sysrq=0

net.ipv4.tcp_timestamps=0
#net.ipv4.tcp_sack=1

net.core.netdev_max_backlog=10000
net.core.rmem_max=4194304
net.core.wmem_max=4194304
net.core.rmem_default=4194304
net.core.wmem_default=4194304
net.core.optmem_max=4194304

#net.ipv4.tcp_rmem="4096 87380 4194304"
#net.ipv4.tcp_wmem="4096 65536 4194304"
net.ipv4.tcp_low_latency=1
net.ipv4.tcp_adv_win_scale=1

# IP Forward
net.ipv4.ip_forward = 1
net.ipv4.ip_forward_update_priority = 1
net.ipv6.conf.all.forwarding=1

net.ipv4.tcp_syncookies=1

# Enable IPv6 Privacy Extensions
net.ipv6.conf.all.use_tempaddr = 2
net.ipv6.conf.default.use_tempaddr = 2

# No kernel dump
fs.suid_dumpable=0
kernel.core_pattern=|/bin/false
Stateful firewall `nftables.conf`

It drops invalid traffic while allowing established connections from the local network and Wi-Fi. Additionally, it permits certain IPv6 traffic from the ISP to establish an IPv6 connection and allows basic ICMP request/reply for monitoring the connection from the internet.

File /etc/nftables.conf

# enp2s0 - uplink
# enp3s0 - local net 192.168.190.0/24 fd11:1:1::/64
# wlp0s21f0u1 - wifi 192.168.191.0/24 fd11:1:2::/64

flush ruleset

table inet filter {
        chain input {
                type filter hook input priority 0; policy drop;
                ct state {established, related} counter accept comment "accept all connections related to connections made by us"
                ct state invalid counter drop comment "early drop of invalid packets"
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
                iif != lo ip6 daddr ::1/128 counter drop comment "drop connections to loopback not coming from loopback"
                tcp flags & (fin|syn|rst|ack) != syn ct state new counter drop
                tcp flags & (fin|syn|rst|psh|ack|urg) == fin|syn|rst|psh|ack|urg counter drop
                tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter drop
                tcp flags syn tcp option maxseg size 1-536 counter drop

                iif enp2s0 ip saddr @snort_block log prefix "nft: snort_block " counter drop

                iif lo accept comment "accept loopback"
                # iif enp2s0 tcp dport 2233 ct state new limit rate 5/minute counter accept comment "accept inet SSH"
                iif {enp3s0, wlp0s21f0u1} tcp dport 2233 counter accept comment "accept local SSH"
                iif wlp0s21f0u1 accept comment "local wifi"
                iif enp3s0 accept comment "local lan"
                iif enp2s0 ip6 daddr fe80::/6 udp dport 546 counter accept
                iif enp2s0 ip6 daddr fe80::/6 udp dport 547 counter accept
                iif enp2s0 icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } ip6 saddr fe80::/10 counter accept comment "ipv6"
                meta l4proto icmp icmp type echo-request limit rate over 10/second burst 4 packets counter drop comment "No ping floods"
                meta l4proto ipv6-icmp icmpv6 type echo-request limit rate over 10/second burst 4 packets counter drop comment "No ping floods"
                ip protocol icmp icmp type {echo-request, echo-reply} counter accept comment "accept all ICMP types"
                icmpv6 type {echo-request, echo-reply} counter accept comment "accept req/reply ICMP types"
                iif enp2s0 counter drop comment "count wan drops"
                counter comment "count dropped packets"
        }

        set snort_block {
                type ipv4_addr; flags dynamic;flags interval;
                elements = {
                163.0.0.0/8,
                157.185.128.0/18,
                138.113.0.0/16,
                14.0.0.0/16
                }
        }

        chain forward {
                type filter hook forward priority 0; policy drop;
                oif enp2s0 udp dport 53 counter drop comment "don't leak DNS"
                oif enp2s0 tcp dport 53 counter drop comment "don't leak DNS"
                ip saddr != 192.168.190.0/24 iifname "enp3s0" counter drop comment "unknown lan src"
                ip saddr != 192.168.191.0/24 iifname "wlp0s21f0u1" counter drop comment "unknown wifi src"
                ip6 saddr != fd11:1:1::/64 iifname "enp3s0" counter drop comment "unknown lan src"
                ip6 saddr != fd11:1:2::/64 iifname "wlp0s21f0u1" counter drop comment "unknown wifi src"

                iif enp2s0 oif enp3s0 ct state related,established counter accept comment "wan -> local"
                iif enp3s0 oif enp2s0 counter accept comment "local -> wan"

                iif enp2s0 oif wlp0s21f0u1 ct state related,established counter accept comment "wan -> wifi"
                iif wlp0s21f0u1 oif enp2s0 counter accept comment "wifi -> wan"

                iif wlp0s21f0u1 oif enp3s0 counter accept comment "wifi -> lan"
                iif enp3s0 oif wlp0s21f0u1 counter accept comment "lan -> wifi"
                counter comment "count dropped packets"
        }

        chain output {
                type filter hook output priority 0; policy accept;
                oif enp2s0 tcp dport 53 counter drop comment "no DNS"
                oif enp2s0 udp dport 53 counter drop comment "no DNS"
                oif lo counter accept comment "count lo"
                counter comment "count accepted packets"
        }
}

table inet nat {
        chain prerouting {
                type nat hook prerouting priority 0; policy accept;
        }

        chain postrouting {
                type nat hook postrouting priority 100; policy accept;
                oif enp2s0 masquerade
        }
}
SSH

Let’s use a non-default SSH port, adjust ciphers/MACs/KexAlgorithms security settings and disable password authentication. Put your public ssh key into /root/.ssh/authorized_keys.

File /etc/ssh/sshd_config

Port 2233

Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group14-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256

PermitRootLogin prohibit-password

AuthorizedKeysFile      .ssh/authorized_keys

PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no

UsePAM yes

X11Forwarding no
PrintMotd no
UseDNS no

Subsystem       sftp    /usr/libexec/sftp-server
Uplink DHCP client IPv4+IPv6

There are many ways to set up IPv6, but we will use NAT even for IPv6. This means the router will request only a single IPv6 address instead of an entire subnet. The IPv4 configuration follows the standard approach.

File /etc/dhcpcd.conf


noipv4ll
controlgroup wheel
noipv6rs
noarp
noauthrequired
persistent
nohook hostname resolv.conf timesyncd.conf ntp.conf

#debug
#logfile /var/log/dhcpcd.log

#option domain_name_servers, host_name, interface_mtu, classless_static_routes
require dhcp_server_identifier
vendorclassid

allowinterfaces enp2s0

interface enp2s0
  iaid 01:08:00:01
  ipv6rs
  ipv6
#  ia_pd 01:08:00:01 enp2s0//64 # in case if you want to request a //64 net from the ISP
  ia_na
  clientid
  vendorclassid
  broadcast
  option subnet_mask routers domain_name_servers domain_name broadcast_address dhcp_lease_time dhcp_renewal_time dhcp_rebinding_time sip_server
  option dhcp6_vivso dhcp6_name_servers dhcp6_domain_search #dhcp6_auth
Local net DHCP server IPv4+IPv6

/etc/dnsmasq.conf

port=0
edns-packet-max=4096
interface=enp3s0
interface=wlp0s21f0u1
except-interface=enp2s0
no-dhcp-interface=enp2s0
no-hosts
enable-ra
dhcp-authoritative
strict-order
dhcp-range=enp3s0,192.168.190.50, 192.168.190.250, 48h
dhcp-option=enp3s0,option:netmask,255.255.255.0
dhcp-option=enp3s0,option:router,192.168.190.1
dhcp-option=enp3s0,option:dns-server,192.168.190.1
dhcp-option=enp3s0,option:ntp-server,192.168.190.1
dhcp-option=enp3s0,option:mtu,9192
dhcp-range=enp3s0, fd11:1:1::200, fd11:1:1::f00, 64, 48h
dhcp-range=enp3s0, fd11:1:1::, ra-stateless, slaac, 48h
dhcp-option=enp3s0, option6:dns-server,[fd11:11:1::1]
dhcp-option=enp3s0,option6:information-refresh-time,1h
dhcp-range=wlp0s21f0u1,192.168.191.50, 192.168.91.250, 48h
dhcp-option=wlp0s21f0u1,option:netmask,255.255.255.0
dhcp-option=wlp0s21f0u1,option:router,192.168.191.1
dhcp-option=wlp0s21f0u1,option:dns-server,192.168.191.1
dhcp-option=wlp0s21f0u1,option:ntp-server,192.168.191.1
dhcp-option=wlp0s21f0u1,option:mtu,1492
dhcp-range=wlp0s21f0u1, fd11:1:2::200, fd11:11:2::f00, 64, 48h
dhcp-range=wlp0s21f0u1, fd11:1:2::, ra-stateless, slaac, 48h
dhcp-option=wlp0s21f0u1, option6:dns-server,[fd11:1:2::1]
dhcp-option=wlp0s21f0u1, option6:information-refresh-time,1h
#dhcp-host=08:bf:b1:11:11:11,192.168.190.15,[fd11:1:1::175],ml-lab # static IPs 
#dhcp-host=38:87:d1:11:11:11,192.168.191.200,[fd11:1:2::200],lemur # static IPs
dhcp-leasefile=/var/dnsmasq.leases
dhcp-authoritative
dhcp-rapid-commit
log-dhcp
dhcp-name-match=set:wpad-ignore,wpad
dhcp-ignore-names=tag:wpad-ignore
Hidden WiFi 5GHz WPA3

Depends on your Wi-Fi dongle capabilities you may need to switch from 5GHz to 2.4GHz. I’m using PAU0D AC1200 dongle.

/etc/hostapd/hostapd.conf

interface=wlp0s21f0u1
ssid2="CHANGE_SSID_HERE"
bssid=ff:ff:ff:ff:00:00
country_code=US
max_num_sta=255
ignore_broadcast_ssid=1
multi_ap=0
beacon_int=500
obss_interval=0
dtim_period=2
hw_mode=a
channel=149
ieee80211h=1
ieee80211d=1
ieee80211n=1
ieee80211ac=1
ieee80211w=2
wmm_enabled=1
ht_capab=[HT40+][HT40-][SHORT-GI-20][SHORT-GI-40][LDPC]
vht_oper_chwidth=1
vht_oper_centr_freq_seg0_idx=155
vht_capab=[SHORT-GI-80][TX-STBC-2BY1]
own_ip_addr=127.0.0.1
eapol_key_index_workaround=0
eap_server=0
auth_algs=1
wpa=2
wpa_passphrase=CHANGE_PASSWORD_HERE
wpa_key_mgmt=SAE
wpa_pairwise=CCMP
rsn_pairwise=CCMP
logger_stdout=-1
logger_stdout_level=2
ctrl_interface=/var/run/hostapd
ctrl_interface_group=0
Local net DNS server and DNS blackhole

/etc/dnscrypt-proxy/dnscrypt-proxy.toml

listen_addresses = ['127.0.0.1:53', '127.0.2.1:53', '192.168.190.1:53','192.168.191.1:53','[fd11:1:1::1]:53', '[fd11:1:2::1]:53', '[::1]:53']
max_clients = 250
ipv4_servers = true
ipv6_servers = true
dnscrypt_servers = true
doh_servers = true
require_dnssec = false
require_nolog = true
require_nofilter = true
disabled_server_names = []
force_tcp = false
timeout = 5000
keepalive = 30
log_file = '/var/log/dnscrypt-proxy/dnscrypt-proxy.log'
cert_refresh_delay = 240
fallback_resolvers = ['9.9.9.9:53', '8.8.8.8:53']
ignore_system_dns = true
netprobe_timeout = 60
netprobe_address = '9.9.9.9:53'
log_files_max_size = 10
log_files_max_age = 7
log_files_max_backups = 5
block_ipv6 = false
block_unqualified = true
block_undelegated = true
reject_ttl = 600
cloaking_rules = 'blocked'
cache = true
cache_size = 8192
cache_min_ttl = 43200
cache_max_ttl = 86400
cache_neg_min_ttl = 300
cache_neg_max_ttl = 3600
[local_doh]
[query_log]
  file = '/var/log/dnscrypt-proxy/dnscrypt-query.log'
  format = 'tsv'
  ignored_qtypes = ['DNSKEY', 'NS', 'AAAA']
[nx_log]
  file = '/var/log/dnscrypt-proxy/dnscrypt-nx.log'
  format = 'tsv'
[blacklist]
[ip_blacklist]
[whitelist]
[schedules]
[sources]
  [sources.'public-resolvers']
  urls = ['https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v2/public-resolvers.md', 'https://download.dnscrypt.info/resolvers-list/v2/public-resolvers.md']
  cache_file = 'public-resolvers.md'
  minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
  prefix = ''
  [sources.'relays']
  urls = ['https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v2/relays.md', 'https://download.dnscrypt.info/resolvers-list/v2/relays.md']
  cache_file = 'relays.md'
  minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
  refresh_delay = 72
  prefix = ''
[broken_implementations]
broken_query_padding = ['cisco', 'cisco-ipv6', 'cisco-familyshield']
[anonymized_dns]
[static]

Update it on our taste, there is a bunch of DNS black lists on github.

/etc/dnscrypt-proxy/blocked

dc.services.visualstudio.com 192.168.190.1
izatcloud.net 192.1681.90.1
rogapi.asus.com 192.168.190.1
cxcs.microsoft.net 192.168.190.1
classify-client.services.mozilla.com 192.168.190.1
merino.services.mozilla.com 192.168.190.1
location.services.mozilla.com 192.168.190.1
getpocket.cdn.mozilla.net 192.168.190.1
firefox-api-proxy.cdn.mozilla.net 192.168.190.1
contile.services.mozilla.com 192.168.190.1
incoming.telemetry.mozilla.org 192.168.190.1
client.wns.windows.com 192.168.190.1
api.msn.com 192.168.190.1
prod.detectportal.prod.cloudops.mozgcp.net 192.168.190.1
www.msftconnecttest.com 192.168.190.1
firefox.settings.services.mozilla.com 192.168.190.1
push.services.mozilla.com 192.168.190.1
Local time server

/etc/chrony.conf

pool pool.ntp.org iburst xleave
driftfile /var/lib/chrony/drift
makestep 0.1 3
rtcsync

allow 192.168.190.0/24
allow 192.168.191.0/24
allow fd11:1:1::/64
allow fd11:1:2::/64
Local HTTP server for static content

We will generate net usage stats from vnstat and put them into /var/www/html

/etc/lighttpd/lighttpd.conf

server.modules = (
        "mod_indexfile",
        "mod_access",
)
server.document-root        = "/var/www/html"
server.upload-dirs          = ( "/var/cache/lighttpd/uploads" )
server.errorlog             = "/var/log/lighttpd/error.log"
server.pid-file             = "/run/lighttpd.pid"
server.username             = "_lighttpd"
server.groupname            = "_lighttpd"
server.port                 = 80
server.feature-flags       += ("server.h2proto" => "disable")
server.feature-flags       += ("server.h2c"     => "disable")
server.feature-flags       += ("server.graceful-shutdown-timeout" => 5)
server.http-parseopts = (
)
index-file.names            = ( "index.html" )
url.access-deny             = ( "~", ".inc" )
static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" )
include_shell "/usr/share/lighttpd/create-mime.conf.pl"
server.modules += (
        "mod_staticfile",
)

/var/www/html/index.html

<html>
<head>
</head>
<body style='background-color:#000;color:#90A090'>
<h1>wan</h1>
<img src="wan_hs.png" alt="stat" /><br/>
<img src="wan_d.png" alt="stat" />
<h1>lan</h1>
<img src="lan_hs.png" alt="stat" /><br/>
<img src="lan_d.png" alt="stat" />
<h1>wifi</h1>
<img src="wifi_hs.png" alt="stat" /><br/>
<img src="wifi_d.png" alt="stat" />
</body>
</html>
Network usage statistics

/etc/vnstat.conf

Interface ""
DatabaseDir "/var/lib/vnstat"
Locale "-"
DayFormat    "%Y-%m-%d"
MonthFormat  "%Y-%m"
TopFormat    "%Y-%m-%d"
RXCharacter       "%"
TXCharacter       ":"
RXHourCharacter   "r"
TXHourCharacter   "t"
UnitMode 0
RateUnit 1
RateUnitMode 1
OutputStyle 3
DefaultDecimals 2
HourlyDecimals 1
HourlySectionStyle 2
Sampletime 5
QueryMode 0
List5Mins      24
ListHours      24
ListDays       30
ListMonths     12
ListYears       0
ListTop        10
DaemonUser ""
DaemonGroup ""
BandwidthDetection 1
MaxBandwidth 100
5MinuteHours   48
HourlyDays      4
DailyDays      62
MonthlyMonths  25
YearlyYears    -1
TopDayEntries  20
UpdateInterval 20
PollInterval 5
SaveInterval 5
OfflineSaveInterval 30
MonthRotate 1
MonthRotateAffectsYears 0
CheckDiskSpace 1
BootVariation 15
TrafficlessEntries 1
TimeSyncWait 5
BandwidthDetectionInterval 5
SaveOnStatusChange 1
UseLogging 2
CreateDirs 1
UpdateFileOwner 1
LogFile "/var/log/vnstat/vnstat.log"
PidFile "/run/vnstat/vnstat.pid"
64bitInterfaceCounters -2
DatabaseWriteAheadLogging 0
DatabaseSynchronous -1
HeaderFormat "%Y-%m-%d %H:%M"
HourlyRate 1
SummaryRate 1
SummaryLayout 1
TransparentBg 0
CBackground     "000000"
CEdge           "AEAEAE"
CHeader         "606060"
CHeaderTitle    "AFAFAF"
CHeaderDate     "AFAFAF"
CText           "90F090"
CLine           "B0B0B0"
CLineL          "-"
CRx             "92CF00"
CTx             "606060"
CRxD            "-"
CTxD            "-"

/etc/cron.daily/vnstatimages

#!/bin/sh

vnstati -hs -o /var/www/html/lan_hs.png -i enp3s0 -ne
vnstati -hs -o /var/www/html/wan_hs.png -i enp2s0 -ne
vnstati -hs -o /var/www/html/wifi_hs.png -i wlp0s21f0u1 -ne

vnstati -d -o /var/www/html/lan_d.png -i enp3s0 -ne
vnstati -d -o /var/www/html/wan_d.png -i enp2s0 -ne
vnstati -d -o /var/www/html/wifi_d.png -i wlp0s21f0u1 -ne

Future Improvements

  • VPN server
  • IPS/IDS
  • File system integrity monitoring
  • Email notifications
  • CVE monitoring for tools used by the router

After party

I hope this guide helps you set up a router that you have full control over and that gives you confidence. I’d love to hear your feedback and improve the guide further.

Thank you!

P.S.: Good old human paranoia never fails.