Linux router with WAN, LAN and DMZ
Turning a Linux box into a router: enabling IP forwarding, addressing the three interfaces with systemd-networkd, and a minimal iptables policy that publishes a mail server and a web server from the DMZ.
10.0.1.0/24, eth2 DMZ 10.0.0.0/24. The router holds the .1 in each subnet — 10.0.1.1 on the LAN, 10.0.0.1 on the DMZ.| Role | Interface | Address |
|---|---|---|
| Router, WAN | eth0 | from the ISP (DHCP or static) |
| Router, LAN | eth1 | 10.0.1.1/24
|
| Router, DMZ | eth2 | 10.0.0.1/24
|
| Mail server | — | 10.0.0.10
|
| Web server | — | 10.0.0.20
|
Enable IP forwarding[edit]
Without this the kernel drops anything not addressed to itself, and no amount of firewall rules will make traffic cross between interfaces.
Immediately, until reboot:
sysctl -w net.ipv4.ip_forward=1
Permanently:
cat >/etc/sysctl.d/99-router.conf <<'EOF'
net.ipv4.ip_forward = 1
# Uncomment if the router also routes IPv6:
# net.ipv6.conf.all.forwarding = 1
EOF
sysctl --system
Check the value in force:
sysctl net.ipv4.ip_forward
cat /proc/sys/net/ipv4/ip_forward
Disable it again[edit]
sysctl -w net.ipv4.ip_forward=0
To make that stick, set the value to 0 in
/etc/sysctl.d/99-router.conf and run sysctl --system
— deleting the file only reverts to the kernel default, which is also
0, but leaves nothing documenting the intent.
Turning forwarding off stops routing but leaves the NAT and filter rules loaded. To stand the router down completely, flush them as well — see Tearing it down.
IPForward= in [Network] is deprecated from systemd 256 in favour of IPv4Forwarding= / IPv6Forwarding=. sysctl behaves the same on every version, so the examples above use it.Interfaces with systemd-networkd[edit]
One file per interface in /etc/systemd/network/. The numeric prefix
only controls the order the files are read.
WAN — /etc/systemd/network/10-wan.network:
[Match]
Name=eth0
[Network]
DHCP=ipv4
# Static instead? Drop DHCP= and use:
# Address=203.0.113.10/24
# Gateway=203.0.113.1
# DNS=1.1.1.1
[DHCPv4]
UseDNS=yes
UseNTP=yes
LAN — /etc/systemd/network/20-lan.network:
[Match]
Name=eth1
[Network]
Address=10.0.1.1/24
ConfigureWithoutCarrier=yes
DMZ — /etc/systemd/network/30-dmz.network:
[Match]
Name=eth2
[Network]
Address=10.0.0.1/24
ConfigureWithoutCarrier=yes
Apply and inspect:
systemctl enable --now systemd-networkd
networkctl reload
networkctl status eth1
Handing out addresses on the LAN is a separate job — see DHCP and NTP for the LAN with systemd-networkd.
Minimal firewall[edit]
Default deny inbound and forwarded, allow outbound, and let conntrack carry return traffic. Everything published from the DMZ is opened explicitly.
Policies and baseline[edit]
# Start from a clean slate
iptables -F
iptables -t nat -F
iptables -X
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
# Loopback, and anything conntrack already knows about
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
Services on the router itself[edit]
Only from the LAN. Nothing on the router listens to the WAN.
iptables -A INPUT -i eth1 -p tcp --dport 22 -j ACCEPT # ssh
iptables -A INPUT -i eth1 -p udp --dport 67 -j ACCEPT # dhcp server
iptables -A INPUT -i eth1 -p udp --dport 123 -j ACCEPT # ntp server
iptables -A INPUT -i eth1 -p udp --dport 53 -j ACCEPT # dns, if the router resolves
iptables -A INPUT -i eth1 -p tcp --dport 53 -j ACCEPT
# Ping, rate limited, from anywhere
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 5/s -j ACCEPT
Publish the DMZ servers (DNAT)[edit]
25 is plain SMTP with STARTTLS, 587 is the submission
port (also STARTTLS) and 465 is implicit TLS submission. Drop
whichever of the two submission ports your mail server does not offer.
# Mail -> 10.0.0.10
iptables -t nat -A PREROUTING -i eth0 -p tcp -m multiport \
--dports 25,465,587,993 -j DNAT --to-destination 10.0.0.10
# Web -> 10.0.0.20
iptables -t nat -A PREROUTING -i eth0 -p tcp -m multiport \
--dports 80,443 -j DNAT --to-destination 10.0.0.20
DNAT only rewrites the destination. The packet still has to survive
FORWARD, which is where the matching accepts belong:
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate INVALID -j DROP
iptables -A FORWARD -i eth0 -o eth2 -d 10.0.0.10 -p tcp -m multiport \
--dports 25,465,587,993 -j ACCEPT
iptables -A FORWARD -i eth0 -o eth2 -d 10.0.0.20 -p tcp -m multiport \
--dports 80,443 -j ACCEPT
Egress and inter-zone policy[edit]
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT # LAN -> internet
iptables -A FORWARD -i eth2 -o eth0 -j ACCEPT # DMZ -> internet (updates, outbound mail)
iptables -A FORWARD -i eth1 -o eth2 -j ACCEPT # LAN -> DMZ (administration)
# DMZ -> LAN is deliberately absent. That asymmetry is the whole point of a
# DMZ: if a published server is compromised it must not reach the LAN.
# Return traffic for LAN-initiated connections is already covered by the
# ESTABLISHED,RELATED rule above.
Source NAT[edit]
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
MASQUERADE takes the WAN address as it finds it, which is what you
want on a dynamic ISP address. On a static WAN address SNAT is
marginally cheaper because it does not look the address up per connection:
iptables -t nat -A POSTROUTING -o eth0 -j SNAT --to-source 203.0.113.10
Reaching the servers from the LAN[edit]
A LAN client resolving the public name gets the WAN address, sends the packet to
the router, and the reply comes back from 10.0.0.10 directly —
so the connection never completes. Either give the LAN a split-horizon DNS view
that answers with the internal address, or add hairpin NAT:
iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -d 10.0.0.10 -p tcp -m multiport \
--dports 25,465,587,993 -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -d 10.0.0.20 -p tcp -m multiport \
--dports 80,443 -j MASQUERADE
Split-horizon DNS is the tidier of the two — hairpin NAT hides the client's real address from the server, so its logs show the router for every internal visitor.
Make the rules survive a reboot[edit]
Debian and Ubuntu:
apt install iptables-persistent
iptables-save > /etc/iptables/rules.v4
RHEL, CentOS, Rocky:
dnf install iptables-services
systemctl enable --now iptables
iptables-save > /etc/sysconfig/iptables
INPUT policy saved with no working ssh rule locks you out until you get physical or console access.Verify[edit]
# Rules, with counters, as they are actually loaded
iptables -L -v -n --line-numbers
iptables -t nat -L -v -n
# Watch a DNAT entry appear while testing from outside
conntrack -L -d 10.0.0.20
# From an external host
nc -vz <wan-address> 443
nc -vz <wan-address> 993
If a published port does not answer, check the FORWARD counters
first. Traffic that was DNATed but has no matching FORWARD accept is
the usual cause, and it shows up as a rising policy-drop count rather than an
error anywhere.
Tearing it down[edit]
sysctl -w net.ipv4.ip_forward=0
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
iptables -F
iptables -t nat -F
iptables -X
iptables is usually a front end to nftables (iptables-nft). The syntax above is unchanged; iptables -V reports which backend is in use, and rules created this way are visible with nft list ruleset.