2026-03
When I finally got access to fiber and considered upgrading my network, several complications reared their heads. My house is old, and the cabling channels in the walls are sized rather restrictively, which meant that since I wanted to avoid surface-mounted cabling as much as possible, I was unable to build a single, completely centrally connected ethernet backhaul. This meant that in addition to a central switch, I needed a router with at least two internal LAN ports to connect the rooms in the two sides of the network.
Also, since my cabling was new enough to support it, and multiple devices (like my main computer and NAS) in my home had 2.5G NICs, I wanted a speedy LAN to take advantage of them. For wifi, I specifically did not want that functionality in the router, as I was planning to set up separate dedicated wifi access points for each floor. Several options popped up, but none felt reasonable, as wired routers with multiple 2.5G LAN ports seemed to be both rare and expensive. Ubiquity devices for this niche would set one back more than 300 euros, and at the time, I couldn't even find a Mikrotik option that filled my requirements. There were a couple of (ugly) wifi routers by gaming-oriented brands that matched these specifications for €200-€300, but I felt they weren't for me.
It soon became clear that similarly to my NAS project, what I probably wanted was some kind of a "prosumer" device such as those produced by CWWK and FriendlyElec. When trying to find the least overengineered option, I soon came up with the NanoPi R6S, which had exactly the number of ports I needed for $150, coupled with a beefy processor, 8G of RAM and 64GB of EMMC.
The device arrived with the case already assembled. Build quality feels solid and plugging in a display and keyboard just worked immediately. The device was initially set up with some kind of a desktop environment, which I didn't investigate further.
In addition to serving as a documentation for myself on what I actually did and how to start fixing stuff later when I've forgotten, this document can be considered a guide on how to manually set up a functional linux-based router.
With official images, the installation was trivial - insert an SD card with the image, boot, and when asked, remove it and reboot. Unfortunately I cannot recommend the official images, as they are application images with a layered filesystems that prevent you from updating the kernel and configuring critical components such as filesystem settings. They are clearly intended for fire&forget installations where the device is to be provisioned for some application and then left alone for years instead of being actively maintained.
Instead, one should visit armbian.com and pick an image. I chose minimal Trixie, as Debian has always worked for me and it has an absolutely massive install base, which comes handy if one has to do any problem solving. For this, one does have to run armbian-config from the SD card and install to EMMC manually, which might not be obvious on first glance. After this, it is possible to remove the SD card and reboot, which gives a situation identical to the application images, except with the user in control of everything.
R6S official images come with an intended setup of 2.5G WAN and 1G and 2.5G LAN ports. Because we are not going to use the 2.5G port as WAN, it is smart to rename the ports in order to avoid confusion down the line. Thus, I suggest opening /etc/udev/rules.d/70-persistent-net.rules and alrering it as follows:
SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", KERNELS=="fe1c0000.ethernet", NAME:="eth0" SUBSYSTEM=="net", ACTION=="add", DRIVERS=="r8169", KERNELS=="0003:31:00.0", NAME:="eth1" SUBSYSTEM=="net", ACTION=="add", DRIVERS=="r8169", KERNELS=="0004:41:00.0", NAME:="eth2"
It might be possible to just delete the file instead, which should result in the same naming convention, but I didn't bother to try.
Instead of configuring systemd-networkd directly, Armbian uses Netplan to manage the network interfaces. At least for me, the configuration was read from /etc/netplan/10-armbian.yaml and I changed it to remove dhcp from eth1/eth2 and combine them to a switch-like br0 interface:
network:
version: 2
renderer: networkd
ethernets:
eth0:
dhcp4: true
dhcp4-overrides:
use-dns: true
dhcp6: false
eth1:
dhcp4: false
dhcp6: false
eth2:
dhcp4: false
dhcp6: false
bridges:
br0:
interfaces:
- eth1
- eth2
addresses:
- 10.2.0.1/24
dhcp4: false
dhcp6: false
parameters:
stp: false
If you want a separate VLAN for guest users or IoT shit, consider adding the following or a similar block to the end:
vlans:
vlan20:
id: 20
link: br0
addresses:
- 10.2.20.1/24
In my case, I'm using VLAN ID 20 for IoT devices. The specific ID holds no significance and it can be anything, but based on what I've read, 20 is used for IoT by convention.
One can always edit dhcpd configuration files directly, and I have done so in the past, but these days we have better options. The dnsmasq service is a one-stop DHCP solution for your LAN which handles DHCP and both NTP and DNS proxying. To set this up, we have to break armbian name resolution a bit, because it makes certain assumptions about how things should work.
First, edit /etc/hosts and bind your gateway hostname to its br0 IP, so that other machines can ping the gateway with that name instead of pinging themselves. Also remove the name from the ::1 address, so that machines in LAN won't try to connect to themselves when you aim for the gateway.
127.0.0.1 localhost 10.2.0.1 your-gateway-name ::1 localhost ip6-localhost ip6-loopback fe00::0 ip6-localnet ff00::0 ip6-mcastprefix ff02::1 ip6-allnodes ff02::2 ip6-allrouters
Then, in modern Debian, /etc/resolv.conf is symlinked to /run/systemd/resolve/stub-resolv.conf, which does not seem to work for our purposes as using it will create a name resolution loop. We aim to make the gateway machine and its DNS proxy responsible for resolving names in the LAN, so the order must to go from ISP DNS servers to dnsmasq to LAN. Thus, we alter resolv.conf to contain a single line:
nameserver 127.0.0.1
Then, in /etc/dnsmasq.conf, we point the upstream to where names are really resolved from. The following block lists also other settings which you should set avoid angering your ISP by dealing out DHCP addresses from eth0 (this continues to be a constant nuisance in apartment complexes with ethernet cabling).
# Change this line if you want dns to get its upstream servers from # somewhere other that /etc/resolv.conf resolv-file=/run/systemd/resolve/resolv.conf bind-interfaces expand-hosts
That is enough for the general dnsmasq configuration, but to set up the internal DHCP space, one should also create an additional file in /etc/dnsmasq.d, which contains the actual address dealing information:
# LAN domain for local names interface=br0 domain=lan local=/lan/ # Dynamic DHCP range: start at .33 dhcp-range=10.2.0.33,10.2.0.254,255.255.255.0,6h # Set this as DNS, NTP and GW for clients dhcp-option=option:router,10.2.0.1 dhcp-option=option:dns-server,10.2.0.1 dhcp-option=option:ntp-server,10.2.0.1 # Static hosts dhcp-host=C7-DB-0B-C6-1F-3A,10.2.0.5,printer,6h dhcp-host=A1-DA-B9-C0-95-E9,10.2.0.9,gamingrig,6h
Optionally, add another one for your IoT/guest/whatever vlan, if any:
# Domain for IoT shit interface=vlan20 domain=iot local=/iot/ # Dynamic DHCP range: start at .33 dhcp-range=10.2.20.33,10.2.20.254,255.255.255.0,6h # Set this computer's vlan20 interface as DNS and GW for clients dhcp-option=option:router,10.2.20.1 dhcp-option=option:dns-server,10.2.20.1 dhcp-option=option:ntp-server,10.2.20.1 # Static hosts dhcp-host=5F:4E:4D:FC:F2:06,10.2.20.11,spotlight1,6h dhcp-host=3A:CF:39:7F:54:FA,10.2.20.12,spotlight2,6h dhcp-host=18:92:89:E1:95:85,10.2.20.13,spotlight3,6h dhcp-host=0B:C4:08:E4:33:EE,10.2.20.14,spotlight4,6h
After this, NPT, LAN dhcp, and local and global name resolution should work. I have to admit that writing resolv.conf manually seems iffy, but as far as I know, Armbian uses netplan by default and if one does not want to switch away from it, this sidesteps issues with having to disable systemd-resolved and reconfigure absolutely everything.
Rather minor, but why not - we just wanted to be the master time for devices in the LAN, so install chrony. Then add the following to its configuration file:
# Serve LAN allow 10.2.0.0/24 local stratum 10
Edit the file /etc/sysctl.d/99-router.conf or create it if it does not exist. Personally I do not enable ipv6 forwarding or deal ipv6 addresses into LAN, so your mileage may vary.
# IPv4 net.ipv4.ip_forward = 1 net.ipv4.conf.eth0.rp_filter=1 net.bridge.bridge-nf-call-iptables=0 # IPv6 net.bridge.bridge-nf-call-ip6tables=0
This enables IP4 IP forwarding, and prevents the kernel from immediately throwing packages to trash if they arrive in its external IP but are not targeted for itself.
I still can't speak netfilter, so the following is an iptables script. I'm giving the script as-is without a lot of commenting, but it should be obvious what lines you should edit to configure your internal service ports. Note that the script provides functionality that most commercial routers do not have. Most importantly, it has proper NAT reflection for both UDP and TCP, and it uses deterministic SNAT so that oldschool services such as identd can actually function. You indeed can get a better product by building stuff yourself.
This expert functionality is the reason for why the forwarding blocks appear as complex as they are. Much simpler setups work if one does not have needy local services.
#!/bin/bash
set -eu
WAN=eth0
LAN=br0
VLAN=vlan20
LAN_NET=10.2.0.0/24
VLAN_NET=10.2.20.0/24
PUBLIC_IP="$(ip -4 addr show "$WAN" | awk '/inet / {print $2}' | cut -d/ -f1)"
[ -n "$PUBLIC_IP" ] || { echo "fw: no public IP on $WAN" >&2; exit 1; }
GAMINGRIG="10.2.0.9"
allow() {
# allow_wan_service
local port="$1" protocol="$2" protocols p
if [ "$protocol" = "both" ]; then
protocols="tcp udp"
else
protocols="$protocol"
fi
for p in $protocols; do
iptables -A INPUT -i "$WAN" -d "$PUBLIC_IP" -p "$p" --dport "$port" -m conntrack --ctstate NEW -j ACCEPT
iptables -t nat -A PREROUTING -i "$LAN" -d "$PUBLIC_IP" -p udp --dport "$port" -j REDIRECT --to-ports "$port"
done
}
create_forward() {
# create_forward
local ip="$1" port="$2" protocol="$3"
if [ "$protocol" = "both" ]; then
protocols="tcp udp"
else
protocols="$protocol"
fi
for p in $protocols; do
# Forwarding
iptables -t nat -A PREROUTING -i "$WAN" -p "$p" --dport "$port" -j DNAT --to-destination "$ip:$port"
iptables -A FORWARD -i "$WAN" -o "$LAN" ! -s "$LAN_NET" -p "$p" -d "$ip" --dport "$port" -m conntrack --ctstate NEW -j ACCEPT
# LAN -> LAN
iptables -A FORWARD -i "$LAN" -o "$LAN" -s "$LAN_NET" -d "$ip" -p "$p" --dport "$port" -m conntrack --ctstate NEW -j ACCEPT
# NAT Reflection
iptables -t nat -A PREROUTING -i "$LAN" -d "$PUBLIC_IP" -p "$p" --dport "$port" -j DNAT --to-destination "$ip:$port"
iptables -t nat -A POSTROUTING -o "$LAN" -s "$LAN_NET" -d "$ip" -p "$p" --dport "$port" -j MASQUERADE
done
}
# Flush
iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -X
# Default policies
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
# Allow between interfaces
iptables -A FORWARD -i eth1 -o eth2 -j ACCEPT
iptables -A FORWARD -i eth2 -o eth1 -j ACCEPT
iptables -A FORWARD -i eth1 -o "$VLAN" -j ACCEPT
iptables -A FORWARD -i eth2 -o "$VLAN" -j ACCEPT
# Allow established/related in
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
iptables -A FORWARD -m conntrack --ctstate INVALID -j DROP
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow ping, but ratelimit
iptables -A INPUT -i "$WAN" -p icmp --icmp-type echo-request -m hashlimit --hashlimit 1/second --hashlimit-burst 5 --hashlimit-mode srcip --hashlimit-name ping_limit -j ACCEPT
# Allow LAN and VLAN to reach router services (DHCP/DNS/SSH etc)
iptables -A INPUT -i "$LAN" -j ACCEPT
iptables -A INPUT -i "$VLAN" -j ACCEPT
# Allow LAN -> WAN forwarding
iptables -A FORWARD -i "$LAN" -o "$WAN" -s "$LAN_NET" -j ACCEPT
#iptables -A FORWARD -i "$VLAN" -o "$WAN" -s "$VLAN_NET" -j ACCEPT
# NAT (masquerade) LAN out from WAN
iptables -t nat -A POSTROUTING -o "$WAN" -s "$LAN_NET" -j MASQUERADE
#iptables -t nat -A POSTROUTING -o "$WAN" -s "$VLAN_NET" -j MASQUERADE
################
### Services ###
################
# Uncomment to allow SSH from WAN
# If you do, BE CAREFUL and harden it
#allow 22 tcp
#######################
### Port Forwarding ###
#######################
# Steam shit
create_forward "$GAMINGRIG" 3074 udp
create_forward "$GAMINGRIG" 3478 both
create_forward "$GAMINGRIG" 4379 both
create_forward "$GAMINGRIG" 27015 both
In this script, the VLAN is isolated both from the LAN and the internet, while devices in LAN can reach the VLAN normally and control IoT devices. Uncomment the masquerade and forward lines if you instead want a guest network, where devices in the VLAN are allowed to access the internet.
Note that just doing this is not enough to make the IoT VLAN work out of the box. Any wifi access points you have must expose a separate VLAN network with its own SSID, and redirect DHCP queries in it to the gateway and its vlan20 interface. Technically, if you have a switch behind the router before the wifi access point, the switch also needs to be managed, so that it can be configured to direct VLAN traffic into the correct direction. With unmanaged switches, malicious IoT devices can scan the address space and bypass your gateway just fine. Currently I am unprotected, as managed switches seemed to be around 250€ a piece for 2.5G throughput, but the logic is there if I decide to invest at some point.
I don't know why, but my Armbian experiences with cpupower, (and earlier with cpufrequtils) with respect to the RK3588 chip have not exactly gone well. As far as I know, the CPU isn't detected correctly and power management simply has to be set manually. The following script helps.
#!/bin/sh cpupower --cpu 0-3 frequency-set --min 408MHz --max 1800MHz --governor schedutil cpupower --cpu 4-7 frequency-set --min 408MHz --max 2304MHz --governor schedutil
Save the following blocks as /usr/lib/systemd/system/cpupower.service and /usr/lib/systemd/system/firewall.service and then enable them through the normal systemd commands.
[Unit] Description=cpupower script to run in system start After=network.target [Service] Type=oneshot ExecStart=/path/to/update-cpupower RemainAfterExit=yes [Install] WantedBy=multi-user.target
[Unit] Description=Iptables firewall After=network-online.target [Service] Type=oneshot ExecStart=/path/to/update-firewall RemainAfterExit=yes [Install] WantedBy=multi-user.target
service cpupower enable service firewall enable systemctl daemon-reload
At this stage, the router main setup is basically finished, and one can either restart the whole device or the services that comprise its functionality. It's best to try this stage while still having your keyboard and monitor connected to the router, as applying the netplan or running the firewall script may lock you out.
If you do not want to restart the whole device, refer to the script below and run it line by line, observing the logs of the services and syslog after each step. Look for errors or inconsistencies.
service cpupower restart netplan apply service systemd-networkd restart service systemd-resolved restart service dnsmasq restart service firewall restart
After all this trouble, you can enjoy some network performance benchmarks.
CPU usage does not seem to be affected by transfers that much, so there is ample headroom to make use of the RAM and install whatever other services you may need.