Objective
By the end of this lab one VyOS 1.5 router will carry three VRFs, two of them serving customers on the same address space, and you will have proved the isolation from the kernel rather than from the configuration. You will also have broken it twice — once by putting a route in the wrong place in the tree, once by changing the kernel behind VyOS’s back — and found both from evidence.
The configuration is the easy half and takes ten minutes. Everything after that is about the gap between what the configuration says and what the box is doing, because that gap is where every VRF incident lives.
Architecture
One multi-tenant edge router and two customer routers that do not know about each other and are configured identically.
r1 (multi-tenant edge)
+--------------------------------------------------+
| eth0 management, default VRF, untouched |
| |
| eth1 ---- VRF CUST-A table 1001 192.0.2.1/30 |
| eth2 ---- VRF CUST-B table 1002 192.0.2.5/30 |
| dum0 ---- VRF MGMT table 1003 203.0.113.1/32 |
+-----+--------------------------+-----------------+
| |
bridge LAB-A bridge LAB-B
| |
+-----+------+ +------+-----+
| c1 eth1 | | c2 eth1 |
| 192.0.2.2 | | 192.0.2.6 |
| dum0 | | dum0 |
| 198.51.100.1/24 | 198.51.100.1/24
+------------+ +------------+
Read the two customer routers again. c1 and c2 have the same LAN
address, 198.51.100.1/24, on the same prefix. That is not a mistake in the
lab; it is the condition VRFs exist for. Two tenants who both chose the same
block are the normal case at any provider, and a design that cannot carry them
is a design that turns every customer onboarding into a renumbering project.
| VRF | Table | Interface on r1 | Transit | Tenant LAN behind it |
|---|---|---|---|---|
| CUST-A | 1001 | eth1 | 192.0.2.0/30 | 198.51.100.0/24 via c1 |
| CUST-B | 1002 | eth2 | 192.0.2.4/30 | 198.51.100.0/24 via c2 |
| MGMT | 1003 | dum0 | — | — |
| default | 254 | eth0 | your management LAN | — |
Requirements
- A hypervisor with roughly 6 GiB of free RAM and 30 GiB of free disk for three VyOS VMs. Each needs 1 GiB of RAM and 8 GiB of disk as a floor.
- The VyOS 1.5 LTS ISO. VRF behaviour is version-sensitive; every command is
written against the 1.5 tree with FRR 10.x underneath, matching
vyos-xv-02-vrf-config. - Two isolated layer-2 bridges — LAB-A and LAB-B — each carrying exactly two
NICs. They must not be the same bridge: if
eth1andeth2share a broadcast domain the tenants reach each other at layer 2 and every isolation result here is meaningless. - Console access to all three routers, per the callout above.
sudoon r1. Two tasks read/procand one runsip linkdirectly.
Scenario
You run the edge router for a small provider. Two customers are being brought
onto the same box this week. Both submitted 198.51.100.0/24 as their
internal network, because both took the first block out of the same
documentation example, and neither will renumber. Separately, the network
team wants a management context on the router that customer traffic cannot
reach under any circumstances.
You have one router, and you are not allowed to buy two more.
Tasks
Configuration blocks are written for the [edit] prompt. Where one opens with
configure and you are already at [edit], skip that line; blocks opening
with set or delete continue the session you have open. bash blocks are
operational mode or the hypervisor host, as their comments say.
Task 1 — Baseline, and the routing policy before any VRF exists
Build the two customer routers first. They are identical apart from the transit address and are ordinary single-table routers — nothing in them knows that VRFs exist.
On c1:
configure
set system host-name c1
set interfaces ethernet eth1 address 192.0.2.2/30
set interfaces ethernet eth1 description 'transit to r1'
set interfaces dummy dum0 address 198.51.100.1/24
set interfaces dummy dum0 description 'customer LAN'
set protocols static route 0.0.0.0/0 next-hop 192.0.2.1
commit
save
On c2, the same with the other transit /30:
configure
set system host-name c2
set interfaces ethernet eth1 address 192.0.2.6/30
set interfaces ethernet eth1 description 'transit to r1'
set interfaces dummy dum0 address 198.51.100.1/24
set interfaces dummy dum0 description 'customer LAN'
set protocols static route 0.0.0.0/0 next-hop 192.0.2.5
commit
save
Now, on r1, capture the state you are about to change. The routing policy capture is the one people skip and the one that explains the most later:
JOURNAL="$HOME/lab06"
mkdir -p "$JOURNAL"
show configuration commands > "$JOURNAL/pre-lab-config.txt"
ip -4 rule show > "$JOURNAL/pre-lab-rules-v4.txt"
ip -6 rule show > "$JOURNAL/pre-lab-rules-v6.txt"
ip route show table 254 > "$JOURNAL/pre-lab-main-table.txt"
cat "$JOURNAL/pre-lab-rules-v4.txt"
$ ip -4 rule show0: from all lookup local
32766: from all lookup main
32767: from all lookup defaultIllustrative output
Task 2 — Create the three VRFs and bind the interfaces
Order of entry does not matter. VyOS builds a tree, not a script, and the
vrf node carries a priority that makes it commit ahead of every interface
node. What does matter is that the VRF and the binding are in the same
candidate configuration — a binding to a VRF that will not exist fails
verification.
configure
set system host-name r1
set vrf name CUST-A table 1001
set vrf name CUST-A description 'Customer A tenant'
set vrf name CUST-B table 1002
set vrf name CUST-B description 'Customer B tenant'
set vrf name MGMT table 1003
set vrf name MGMT description 'Operations context, no customer traffic'
set interfaces ethernet eth1 vrf CUST-A
set interfaces ethernet eth1 address 192.0.2.1/30
set interfaces ethernet eth1 description 'CUST-A transit to c1'
set interfaces ethernet eth2 vrf CUST-B
set interfaces ethernet eth2 address 192.0.2.5/30
set interfaces ethernet eth2 description 'CUST-B transit to c2'
set interfaces dummy dum0 vrf MGMT
set interfaces dummy dum0 address 203.0.113.1/32
set interfaces dummy dum0 description 'MGMT anchor'
commit
save
Three table ids, all inside the 100-65535 window the validator enforces, none
of them 254, none duplicated, and all in the band above 1000 that
vyos-xiv-03-table-id-namespace recommends reserving for VRFs so a
ip route show table N result identifies its own owner.
Now look at what the commit did to the whole box:
ip -4 rule show
diff -u "$HOME/lab06/pre-lab-rules-v4.txt" <(ip -4 rule show)
$ ip -4 rule show1000: from all lookup [l3mdev-table]
2000: from all lookup [l3mdev-table] unreachable
32765: from all lookup local
32766: from all lookup main
32767: from all lookup defaultIllustrative output
Record this diff. It is the single most useful thing to have in a journal the first time somebody asks why the box behaved differently after “just adding a VRF for the new customer”.
Task 3 — The four-way validation
A VRF is right when four independent views agree. Any one of them can be true while the others are false, so check all four for each VRF; the habit is what Task 8 depends on.
### 1 - the VyOS view
show vrf
show vrf CUST-A
### 2 - the VRF master device exists and owns the table
ip -d link show dev CUST-A
### 3 - the slave reports its master and the table its packets use
ip -d link show dev eth1
### 4 - the per-VRF table holds the connected route, and main does not
ip route show table 1001
ip route show table 254
$ ip -d link show dev eth13: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master CUST-A state UP mode DEFAULT group default qlen 1000
link/ether 52:54:00:aa:00:01 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
vrf_slave table 1001 addrgenmode eui64 numtxqueues 1 numrxqueues 1Illustrative output
The evidence that isolation is working is an absence: 192.0.2.0/30 is in
table 1001 and is not in table 254. Confirm that explicitly rather than
glancing at it — a connected route in the main table means the interface is
not enslaved, whatever the configuration tree says.
if ip route show table 254 | grep -q '192.0.2'; then
echo 'LEAK: a tenant transit prefix is in the main table'
else
echo 'not in main - correct'
fi
Note the device name your box reported in step 2 and settle the naming question from the Requirements callout now.
Repeat all four for CUST-B (table 1002, eth2) and MGMT (table 1003, dum0).
MGMT is worth a second look: it has exactly one member and no transit, which
is a shape show vrf will happily present as healthy. A VRF listed with no
interfaces at all is a VRF nothing can enter.
Task 4 — Two tenants, one prefix
Give each VRF a route to its own customer’s LAN. Note that the two statements carry the same prefix and differ only in which VRF they are written under and which next-hop they name:
configure
set vrf name CUST-A protocols static route 198.51.100.0/24 next-hop 192.0.2.2
set vrf name CUST-A protocols static route 198.51.100.0/24 description 'CR-LAB06 tenant A LAN'
set vrf name CUST-B protocols static route 198.51.100.0/24 next-hop 192.0.2.6
set vrf name CUST-B protocols static route 198.51.100.0/24 description 'CR-LAB06 tenant B LAN'
commit
save
Put the three tables next to each other in the journal:
ip route show table 1001
ip route show table 1002
ip route show table 254
Table 1001 and table 1002 both hold 198.51.100.0/24, with different
next-hops and different egress interfaces. Table 254 holds neither. One box,
one prefix, two answers, and nothing overlapping in the global table.
Now prove the two routes reach different machines. Start a capture on each customer router, then ping the same address twice from r1 with different VRF scoping:
### On c1 and on c2, each in its own session
timeout 30 tcpdump -ni eth1 -c 4 icmp
### On r1
ping -c 2 198.51.100.1 vrf CUST-A
ping -c 2 198.51.100.1 vrf CUST-B
ping -c 2 198.51.100.1
The first ping is answered and only c1’s capture sees it. The second is
answered and only c2’s capture sees it. The third — the unscoped one — fails,
because the main table has no route to 198.51.100.0/24 at all.
That third result is worth dwelling on. The address is identical in all three commands. The only difference is the routing context, and it decided which physical machine answered. This is what “a routable slice of a router” means in practice, and it is why an unscoped reachability test on a multi-VRF box is not evidence of anything.
Task 5 — Prove a cross-VRF miss is rejected, not forwarded
This is the test that distinguishes “the tables happen not to overlap” from “the boundary is enforced”.
From c1, which is inside CUST-A, try to reach two things that exist on r1 but not in CUST-A’s table:
### On c1
ping -c 2 203.0.113.1
ping -c 2 192.0.2.5
traceroute -n 203.0.113.1
203.0.113.1 is r1’s MGMT anchor and 192.0.2.5 is r1’s CUST-B transit
address. Both are configured on r1. Both are unreachable from c1, and they are
unreachable for the same reason: the packet arrives on eth1, rule 1000 sends
the lookup to table 1001, table 1001 has no match, and rule 2000 terminates
the walk with unreachable before main at 32766 is ever consulted.
Watch it happen from r1’s side while c1 pings:
### On r1
timeout 20 tcpdump -ni eth1 icmp
The echo requests arrive. r1 is not dropping them at a firewall — there is no firewall in this lab — it is failing to find a route for them in the only table it is allowed to consult, and answering accordingly.
Task 6 — The route in the wrong place
This is the commonest VRF defect in production, and it is worth producing
deliberately once. It arrives when somebody converts a working single-table
router to a VRF design by adding vrf to the interfaces and leaving the
protocols block where it was.
Add a route for a new tenant prefix, at the top of the tree, where it does not belong:
configure
set protocols static route 203.0.113.128/25 next-hop 192.0.2.2
set protocols static route 203.0.113.128/25 description 'CR-LAB06 new tenant A subnet'
commit
The commit succeeds. Nothing warns. Now find the defect:
show ip route 203.0.113.128/25
show ip route vrf CUST-A 203.0.113.128/25
ip route show table 254
ip route show table 1001
show configuration commands | match '^set protocols'
The route is in the default routing instance and not in CUST-A’s. Worse, it
does not work in the default instance either: its next-hop 192.0.2.2 lives
on a subnet whose interface is enslaved to CUST-A, so the main table cannot
resolve it. FRR keeps the route, marks the next-hop unusable, and never hands
it to the kernel — a route that is present in show ip route and absent from
ip route show.
That last command is the five-second check. On a router whose routing is
entirely per-VRF, show configuration commands | match '^set protocols'
should return nothing at all. Anything it returns is either deliberate global
routing or a misplacement, and you should be able to say which.
Move it where it belongs:
delete protocols static route 203.0.113.128/25
set vrf name CUST-A protocols static route 203.0.113.128/25 next-hop 192.0.2.2
set vrf name CUST-A protocols static route 203.0.113.128/25 description 'CR-LAB06 new tenant A subnet'
commit
save
Confirm with both views — show ip route vrf CUST-A 203.0.113.128/25 for
FRR’s opinion, and ip route show table 1001 for the fact.
Task 7 — Deletion order, and the reference check
VyOS refuses to delete a VRF that anything still references, and it checks the candidate configuration rather than the running one. That is not an obstacle; it is what makes the correct fix a single commit.
Try the wrong way first, so you recognise the message:
configure
delete vrf name MGMT
commit
The commit is rejected, naming MGMT and stating that it still has member interfaces. Nothing was applied — the rejection is itself the safe state.
Clear the candidate before retrying, because it still holds the delete you
just made and VyOS refuses a second delete of a node that is already gone
from the candidate:
discard
The right way removes the reference and the VRF together, in one candidate:
delete interfaces dummy dum0 vrf
delete vrf name MGMT
commit
Then put MGMT back, because the rest of the lab wants three VRFs:
set vrf name MGMT table 1003
set vrf name MGMT description 'Operations context, no customer traffic'
set interfaces dummy dum0 vrf MGMT
commit
save
Two more references block a delete the same way, and both are easier to hit than the interface case: static routes configured inside the VRF, and policy routes that name it. If a decommission commit is rejected and you cannot see a member interface, read the end of the message — it names which kind of reference is left.
Task 8 — Kernel drift, and why ip link is a probe rather than a repair
Everything so far trusted the configuration. This task is about what happens when the kernel stops agreeing with it, which is the failure mode the four-way check in Task 3 exists to catch.
Simulate what an engineer does during an unrelated incident and then forgets:
$ sudo ip link set dev eth1 nomasterNow run the four-way check again and watch it disagree with itself:
show configuration commands | match 'eth1'
ip -d link show dev eth1
show vrf
ip route show table 1001
ip route show table 254
The configuration still says set interfaces ethernet eth1 vrf CUST-A.
ip -d link show dev eth1 has no master field and no vrf_slave line.
Table 1001 has lost the connected route for the tenant transit, so the static
route to 198.51.100.0/24 has nothing to resolve through and has gone
inactive. And table 254 has gained the tenant subnet.
Prove the consequence, which is the part to internalise:
ping -c 2 192.0.2.2
An unscoped ping from r1’s global context now reaches the tenant. For the
whole period between the ip link command and the repair, the tenant subnet
is routed by the main table and reachable from the global context, while every
line of the configuration says it is isolated. Write down, in the journal, how
long that window lasted for you and what an operator in the global context
could have reached during it.
A commit of an unchanged configuration will not fix this. Commit scripts run for nodes that changed, and nothing changed — the configuration already says what you want. Force the interface node to run again by removing and re-adding the binding in one commit:
configure
delete interfaces ethernet eth1 vrf
set interfaces ethernet eth1 vrf CUST-A
commit
save
Re-run all four checks and confirm they agree again, including that the tenant subnet has left table 254.
Validation
ip -4 rule showon r1 shows rules 1000 and 2000 abovelocalat 32765, and your journal holds the before-and-after diff.show vrflists CUST-A, CUST-B and MGMT, eachup, with exactly the intended interface in its Interfaces column.ip -d link show dev eth1reportsmasterandvrf_slave table 1001;eth2reports the CUST-B equivalent.ip route show table 1001andip route show table 1002both contain198.51.100.0/24, with different next-hops.ip route show table 254contains neither it nor192.0.2.0/30nor192.0.2.4/30.ping 198.51.100.1 vrf CUST-Aandping 198.51.100.1 vrf CUST-Bboth answer; the unscopedping 198.51.100.1does not. Your captures identify which customer router answered each of the first two.- From c1,
ping 203.0.113.1andping 192.0.2.5both fail, and you can state whichip ruleentry produced the failure. show configuration commands | match '^set protocols'returns nothing.- After the Task 8 repair, the four-way check agrees on every point and
ping 192.0.2.2from r1’s global context fails again.
Expected Outcome
r1 ends with three VRFs, three tables, and nothing shared:
vrf {
name CUST-A {
description "Customer A tenant"
protocols {
static {
route 198.51.100.0/24 {
description "CR-LAB06 tenant A LAN"
next-hop 192.0.2.2 {
}
}
route 203.0.113.128/25 {
description "CR-LAB06 new tenant A subnet"
next-hop 192.0.2.2 {
}
}
}
}
table 1001
}
name CUST-B {
description "Customer B tenant"
protocols {
static {
route 198.51.100.0/24 {
description "CR-LAB06 tenant B LAN"
next-hop 192.0.2.6 {
}
}
}
}
table 1002
}
name MGMT {
description "Operations context, no customer traffic"
table 1003
}
}
Both customers reach their own LAN through r1, neither can reach the other or the management context, and the global table on r1 knows about neither of them.
Troubleshooting
The commit is rejected for a VRF name. Names become Linux interface names,
so they are limited to 15 characters, letters, digits, - and _; they must
not begin with an interface-type string such as eth, bond, br, tun,
vti, wg or lo; and a list of ip keywords including default, all,
link, type and vrf is reserved outright. The message names the
constraint. Nothing is applied.
The commit is rejected for a table id. It is mandatory, must be between 100 and 65535, must not be 254, must be unique across VRFs, and cannot be changed once the VRF interface exists — the kernel offers no way to re-table a live vrf device, so a change is a delete and a recreate.
show vrf lists the VRF but its Interfaces column is empty. The binding
did not take. Go straight to ip -d link show on the interface you expected;
this is the Task 8 shape.
The static route inside the VRF is in show ip route vrf and not in ip route show table. Its next-hop did not resolve inside that VRF. Confirm the
egress interface is enslaved to the same VRF, and confirm the next-hop is on a
subnet the VRF actually holds a connected route for.
Both customers are reachable from each other. Check that eth1 and eth2
are on different bridges. Two VRFs on one router cannot save you from one
broadcast domain: if the tenants share a layer-2 segment they will find each
other without r1 forwarding anything.
A tenant subnet appears in ip route show table 254. The interface is not
enslaved. This is a security finding, not a routing bug — record when it
started before you fix it.
Cleanup
Cleanup returns r1 to the Task 1 capture, including the routing policy. That last part is what makes this cleanup different from the other labs: removing the last VRF changes the box’s global forwarding behaviour back, and you should watch it happen rather than assume it.
Step 1. On r1, remove every reference and every VRF in one commit, because the reference check will reject anything less:
configure
delete interfaces ethernet eth1 vrf
delete interfaces ethernet eth2 vrf
delete interfaces dummy dum0 vrf
delete vrf name CUST-A
delete vrf name CUST-B
delete vrf name MGMT
delete interfaces ethernet eth1 address 192.0.2.1/30
delete interfaces ethernet eth2 address 192.0.2.5/30
delete interfaces dummy dum0
delete interfaces ethernet eth1 description
delete interfaces ethernet eth2 description
commit
save
Run compare before that commit. The diff must reverse the whole change,
not just the VRFs — a rejection here means a reference was missed, and the
message names which kind.
Step 2. Confirm the routing policy unwound:
JOURNAL="$HOME/lab06"
ip -4 rule show > "$JOURNAL/post-lab-rules-v4.txt"
diff -u "$JOURNAL/pre-lab-rules-v4.txt" "$JOURNAL/post-lab-rules-v4.txt" \
&& echo 'ROUTING POLICY RESTORED'
show configuration commands > "$JOURNAL/post-lab-config.txt"
diff -u "$JOURNAL/pre-lab-config.txt" "$JOURNAL/post-lab-config.txt" \
&& echo 'CONFIG RESTORED'
local should be back at priority 0 and both l3mdev rules gone. If they are
still there, a VRF still exists somewhere — ip -d link show type vrf finds
it.
Step 3. On c1 and c2, remove what Task 1 added and check the same way:
configure
delete protocols static
delete interfaces dummy dum0
delete interfaces ethernet eth1 address
delete interfaces ethernet eth1 description
commit
save
Step 4. If the routers exist only for this lab, destroy them.
### On the Proxmox host, once per router
VMID=301
qm stop "$VMID"
qm destroy "$VMID"
Production notes
The first VRF on a box is a change to the whole box. Adding one for a new
customer rewrites the routing policy for every packet the router handles,
management traffic included. It belongs in a change window with the
ip -4 rule show diff attached to the ticket, not in the “small additive
change” queue where it usually ends up.
Table ids are an allocation problem, not a syntax problem. VRF tables and policy-routing tables share one kernel namespace and nothing cross-checks them. Keep one allocation list for both, reserve a band for VRFs, and write the id into the change request — it cannot be changed later without deleting and recreating the VRF, so it is a permanent decision made at the moment it feels least important.
Moving an interface between VRFs is a tenant outage. Both the delete and
the add move a live subnet between tables. Run it under commit-confirm — see
vyos-lab-03-commit-rollback — whenever the management path could traverse
the interface, and re-check the firewall rule set for anything that assumed
the global context.
Overlapping address space is legal, works, and has a bill attached. It costs nothing until two tenants must reach the same shared service, and then it costs a NAT design or a renumbering. Decide at onboarding and write the answer down.
Holding is a legitimate response to the Task 8 finding. The repair moves a subnet between tables and interrupts the tenant, so scheduling it is often right — but the exposure window is a security event either way, and gets an owner, an end time and a written record.
What You Learned
- Isolation is two
ip ruleentries installed by a commit script. Rule 1000 directs the lookup, rule 2000 stops the walk. You saw both arrive with the first VRF and you know they leave with the last. - The configuration tree is not evidence. Four views have to agree, and Task 8 showed a box where three were wrong and the configuration was the most confident of all.
- The same prefix in two tables is the normal case. Two tenants, one block, two answers, decided by which VRF the packet was in — and an unscoped ping that answers neither.
- A cross-VRF miss is rejected, not forwarded. There is no implicit
fall-back to the global table; building one is a deliberate act, covered in
vyos-lab-07-vrf-leak. - Placement in the tree is the commonest VRF defect.
show configuration commands | match '^set protocols'returning nothing is worth checking on every multi-VRF router you inherit. ip linksurgery is a probe, never a repair. It fixes the symptom and schedules the same outage for a time nobody chooses.