Skip to main content
RunBook Academy

← All labs in VyOS

Lab · advanced · ~100 min

Lab: VRF Isolation

B · Nested virtualisationC · Simulation

Objectives

  • Create three VRFs with table ids the validator accepts, bind interfaces to them, and confirm the enslavement at the kernel level rather than from the configuration tree
  • Observe the routing-policy rewrite VyOS performs on the whole box the moment its first VRF exists
  • Run two tenants on the same address space and prove that one prefix resolves to two different machines depending only on which VRF the packet is in
  • Prove a cross-VRF miss is rejected rather than falling through to the main table
  • Diagnose a static route placed at the top of the tree instead of inside the VRF, from evidence rather than by re-reading the configuration
  • Recognise kernel drift — a configuration that claims isolation while the interface is no longer enslaved — and repair it through the configuration, not through `ip link`

Prerequisites

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.

VRFTableInterface on r1TransitTenant LAN behind it
CUST-A1001eth1192.0.2.0/30198.51.100.0/24 via c1
CUST-B1002eth2192.0.2.4/30198.51.100.0/24 via c2
MGMT1003dum0
default254eth0your 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 eth1 and eth2 share 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.
  • sudo on r1. Two tasks read /proc and one runs ip link directly.

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"
Read-only / Safer1 · routing policy on a box with no VRFs
$ ip -4 rule show
0:	from all lookup local
32766:	from all lookup main
32767:	from all lookup default

Illustrative 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)
Read-only / Safer1 · routing policy once a VRF exists
$ ip -4 rule show
1000:	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 default

Illustrative 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
Read-only / Safer1 · the slave's view is the one that settles it
$ ip -d link show dev eth1
3: 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 1

Illustrative 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.

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:

Service impact possibler1 · deliberate out-of-band change
$ sudo ip link set dev eth1 nomaster

Now 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 show on r1 shows rules 1000 and 2000 above local at 32765, and your journal holds the before-and-after diff.
  • show vrf lists CUST-A, CUST-B and MGMT, each up, with exactly the intended interface in its Interfaces column.
  • ip -d link show dev eth1 reports master and vrf_slave table 1001; eth2 reports the CUST-B equivalent.
  • ip route show table 1001 and ip route show table 1002 both contain 198.51.100.0/24, with different next-hops. ip route show table 254 contains neither it nor 192.0.2.0/30 nor 192.0.2.4/30.
  • ping 198.51.100.1 vrf CUST-A and ping 198.51.100.1 vrf CUST-B both answer; the unscoped ping 198.51.100.1 does not. Your captures identify which customer router answered each of the first two.
  • From c1, ping 203.0.113.1 and ping 192.0.2.5 both fail, and you can state which ip rule entry 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.2 from 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 rule entries 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 link surgery is a probe, never a repair. It fixes the symptom and schedules the same outage for a time nobody chooses.

Deliverables

  • · A lab journal holding `ip -4 rule show` from before and after the first VRF commit
  • · The four-way validation capture for each VRF: `show vrf`, the VRF device, the slave interface, and the per-VRF table
  • · Side-by-side dumps of table 1001, table 1002 and table 254 showing the same prefix in two of them and in neither of the others
  • · Packet evidence identifying which of two identically addressed customer routers answered each ping
  • · A written statement of what an operator could reach during the drift window in Task 8, and for how long

Verification status

Last reviewed
2026-08-19
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.