Linux router with WAN, LAN and DMZ

From LinuxizeNet

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.

Interfaces and addresses used throughout: eth0 WAN, eth1 LAN 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.

systemd-networkd can set forwarding itself, but the option was renamed: 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
Save only after you have confirmed you can still reach the box. A default-DROP 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
On current distributions 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.