Protecting the router
State first
Almost every rule you write is about the first packet of a connection. Replies and the rest of an established connection should simply be allowed, in every hook. VyOS does this once for the whole firewall with global-options state-policy: established and related packets are accepted, and invalid ones (packets that belong to no connection the router knows) are dropped, before any of your rules are checked.
Groups
Rules that name addresses directly get repeated and go stale. Groups give them a name instead:
- an address-group (single addresses:
ADMIN-HOMES), - a network-group (prefixes:
MGMT-NETS), - a port-group (
WEB-PORTS), and - an interface-group (
LAN-IFS).
Change the group, and every rule that uses it changes with it.
A custom chain for SSH
All SSH traffic to the router is sent with one jump rule to a named ruleset, SSH-IN, which reads top to bottom like a policy:
- The management network may always connect.
- Anyone else is limited to 3 new connections a minute, logged when they go over. (
recentkeeps a per-source counter of new connections.) - Administrators from home are then accepted.
- Everything else is dropped, and logged.
Ping is allowed, and the input filter drops everything else. The router now has no open doors except the ones on purpose.
Testing it
From the management network, five connections in a row all get SSH's banner. The scanner gets nothing at all. From home, the first three connections work and the next two are dropped by the rate limit, so a password-guessing script crawls instead of running at full speed.
The counters tell the same story, rule by rule, and the log shows each dropped packet with its source address. Notice that the log prefix ([ipv4-NAM-SSH-IN-20-D]) names the chain, rule and action, so you can tell which rule dropped it without guessing.
set firewall global-options state-policy established action 'accept'
set firewall global-options state-policy related action 'accept'
set firewall global-options state-policy invalid action 'drop'
set firewall group network-group MGMT-NETS network '10.99.0.0/24'
set firewall group address-group ADMIN-HOMES address '192.0.2.10'
set firewall ipv4 name SSH-IN default-action 'drop'
set firewall ipv4 name SSH-IN default-log
set firewall ipv4 name SSH-IN description 'Who may SSH to the router'
set firewall ipv4 name SSH-IN rule 10 action 'accept'
set firewall ipv4 name SSH-IN rule 10 description 'Management network: always'
set firewall ipv4 name SSH-IN rule 10 source group network-group 'MGMT-NETS'
set firewall ipv4 name SSH-IN rule 20 action 'drop'
set firewall ipv4 name SSH-IN rule 20 description 'Anyone else: at most 3 new connections a minute'
set firewall ipv4 name SSH-IN rule 20 recent count '3'
set firewall ipv4 name SSH-IN rule 20 recent time 'minute'
set firewall ipv4 name SSH-IN rule 20 state 'new'
set firewall ipv4 name SSH-IN rule 20 log
set firewall ipv4 name SSH-IN rule 30 action 'accept'
set firewall ipv4 name SSH-IN rule 30 description 'Admins from home'
set firewall ipv4 name SSH-IN rule 30 source group address-group 'ADMIN-HOMES'
set firewall ipv4 input filter default-action 'drop'
set firewall ipv4 input filter rule 10 action 'accept'
set firewall ipv4 input filter rule 10 protocol 'icmp'
set firewall ipv4 input filter rule 20 action 'jump'
set firewall ipv4 input filter rule 20 jump-target 'SSH-IN'
set firewall ipv4 input filter rule 20 protocol 'tcp'
set firewall ipv4 input filter rule 20 destination port '22'- Lines 1–3: Once for the whole firewall: replies accepted, invalid packets dropped, before any rule.
- Lines 4–5: Groups: a network for management, an address for the admin's home.
- Lines 6–8: A named ruleset. Its own default: drop, and log.
- Lines 9–11: Management may always SSH in.
- Lines 12–17: Anyone else: more than three new connections a minute are dropped, and logged.
- Lines 18–20: Then the admin's home address is accepted.
- Lines 21–27: The input filter: ping allowed, SSH sent to the chain, everything else dropped.
root@admin:~# for i in 1 2 3 4 5; do printf "try $i: "; nc -w 3 10.99.0.1 22 </dev/null | head -1 | grep . || echo "no answer"; done
try 1: SSH-2.0-OpenSSH_9.2p1
try 2: SSH-2.0-OpenSSH_9.2p1
try 3: SSH-2.0-OpenSSH_9.2p1
try 4: SSH-2.0-OpenSSH_9.2p1
try 5: SSH-2.0-OpenSSH_9.2p1
root@scanner:~# nc -w 3 203.0.113.2 22 </dev/null | head -1 | grep . || echo "no answer"
no answer
root@home:~# for i in 1 2 3 4 5; do printf "try $i: "; nc -w 3 203.0.113.2 22 </dev/null | head -1 | grep . || echo "no answer"; done
try 1: SSH-2.0-OpenSSH_9.2p1
try 2: SSH-2.0-OpenSSH_9.2p1
try 3: SSH-2.0-OpenSSH_9.2p1
try 4: no answer
try 5: no answer- Lines 1–6: Management: never limited.
- Lines 8–9: The scanner: no answer at all.
- Lines 11–16: home: three connections, then the rate limit drops the rest.
vyos@R1:~$ show firewall ipv4 input filter
Ruleset Information
---------------------------------
ipv4 Firewall "input filter"
Rule Action Protocol Packets Bytes Conditions
------- -------- ---------- --------- ------- ------------------------------------
10 accept icmp 0 0 meta l4proto icmp accept
20 jump tcp 30 1800 tcp dport 22 jump NAME_SSH-IN
default drop all 0 0 comment "INP-filter default-action "
vyos@R1:~$ show firewall ipv4 name SSH-IN
Ruleset Information
---------------------------------
ipv4 Firewall "name SSH-IN"
Rule Action Protocol Packets Bytes Conditions
------- -------- ---------- --------- ------- -----------------------------------------------------------------------------------------------------------------------------
10 accept all 6 360 ip saddr @N_MGMT-NETS accept
20 drop all 12 720 ct state new add @RECENT_NAM_SSH-IN_20 { ip saddr limit rate over 3/minute burst 3 packets } prefix "[ipv4-NAM-SSH-IN-20-D]"
30 accept all 6 360 ip saddr @A_ADMIN-HOMES accept
default drop all 6 360 prefix "[ipv4-NAM-SSH-IN-default-D]" comment "NAM-SSH-IN default-action "- Line 10: Every SSH packet to R1 went to the chain.
- Line 21: Management's packets, accepted.
- Line 22: Packets over the limit: the 4th and 5th tries, and their retries. These counters span two test runs.
- Lines 23–24: Accepted from home, and the scanner's attempts dropped by the default.
vyos@R1:~$ show log firewall | tail -3
Oct 21 07:55:48 kernel: [ipv4-NAM-SSH-IN-20-D]IN=eth0 OUT= MAC=52:54:00:11:00:01:96:90:69:9c:34:14:08:00 SRC=192.0.2.10 DST=203.0.113.2 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=19764 DF PROTO=TCP SPT=44712 DPT=22 WINDOW=64240 RES=0x00 SYN URGP=0
Oct 21 07:55:49 kernel: [ipv4-NAM-SSH-IN-20-D]IN=eth0 OUT= MAC=52:54:00:11:00:01:96:90:69:9c:34:14:08:00 SRC=192.0.2.10 DST=203.0.113.2 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=19765 DF PROTO=TCP SPT=44712 DPT=22 WINDOW=64240 RES=0x00 SYN URGP=0
Oct 21 07:55:50 kernel: [ipv4-NAM-SSH-IN-20-D]IN=eth0 OUT= MAC=52:54:00:11:00:01:96:90:69:9c:34:14:08:00 SRC=192.0.2.10 DST=203.0.113.2 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=19766 DF PROTO=TCP SPT=44712 DPT=22 WINDOW=64240 RES=0x00 SYN URGP=0- Lines 2–4: Each dropped packet: the log prefix names the chain (SSH-IN), the rule (20) and the action (D for drop), then the interface and source address.
Check your understanding
The SSH-IN chain accepts the management network at rule 10 and rate-limits at rule 20. What would change if those two rules swapped numbers?
- Admins on the management network would be rate-limited too.
- Nothing: VyOS sorts accepts before drops.
- The management network would be blocked entirely.
- The commit would fail.