Skip to main content
RunBook Academy

← All break/fix scenarios in VyOS

advancedvyos-policy-route~30 min

Policy Route Sends Traffic to Wrong WAN

Reported symptoms

  • The finance team at the Northgate depot cannot log in to the SaaS ERP; the vendor returns an access-denied page rather than a password error, and a ticket has been open with the vendor for nine days
  • Call quality at the same site has got worse since the voice-quality project completed, which is the opposite of what the project was for, and the voice carrier has been asked to investigate its new circuit
  • The nightly off-site backup from the depot now overruns its window and has been killed by the scheduler on six of the last eleven nights
  • General internet browsing from the depot is completely normal, and staff report no problem with anything except the ERP
  • The site-to-site IPsec tunnel to HQ is up, has not flapped once, and carries everything routed to HQ without complaint - which every responder so far has read as proof that the network is healthy
  • One change touched this router in the last three months: a policy route added eleven days ago to send voice traffic out the new low-latency circuit. It was tested at the time by placing a call and watching the packets leave the correct interface, and the change was closed

Evidence

  • · `ip rule show` on rtr-edge-1 prints `100: from 10.10.0.0/16 iif eth1 lookup 100`, and the voice VLAN the rule was written for is 10.10.0.0/24
  • · `ip route show table 100` contains a single entry, `default via 198.51.100.1 dev eth3`, pointing at the voice circuit
  • · `ip route get 203.0.113.50 from 10.10.0.5 iif eth1` resolves via 198.51.100.1 dev eth3 - the voice source takes the voice circuit, exactly as designed
  • · `ip route get 203.0.113.50 from 10.10.20.44 iif eth1` also resolves via 198.51.100.1 dev eth3, and 10.10.20.44 is a finance workstation on the data VLAN
  • · `vtysh -c "show route-map RM-VOICE-OUT"` reports a matching counter three orders of magnitude larger than the depot call volume can account for
  • · `show interfaces` shows eth3, the 50 Mbit voice circuit, carrying almost all site egress traffic and running at its shaper ceiling during the backup window, while eth0 toward the primary ISP is close to idle
  • · `tcpdump -ni eth3 "src net 10.10.20.0/24"` on the voice circuit captures data-VLAN traffic immediately
  • · An external what-is-my-address service queried from a data-VLAN workstation returns the voice circuit public address, not the primary ISP address the SaaS vendor allowlisted
  • · `show configuration commands | match prefix-list` shows the voice source prefix-list holding 10.10.0.0/16
  • · The IPsec tunnel to HQ is sourced from the router itself and still leaves via eth0, because the policy rule carries an `iif eth1` selector and router-originated packets are not received on eth1
Diagnosis and resolutionclick to reveal

Root cause

The prefix-list feeding the policy route holds 10.10.0.0/16 where the voice VLAN is 10.10.0.0/24. The two look almost identical in a configuration diff and are wildly different as a selector: the /24 covers the 254 addresses the policy was written for, and the /16 covers every subnet at the depot - data, guest, servers and CCTV included. All of those subnets arrive at the router on eth1 from the campus layer-3 switch, so all of them satisfy the rule, and every packet the depot sends to the internet has been leaving through a 50 Mbit circuit contracted to carry a few dozen concurrent calls. Nothing is broken. The router is doing exactly what it was told, which is why eleven days of investigation by three teams found nothing wrong in any of the three systems they were looking at. Each reported symptom is a different consequence of one changed egress. The SaaS vendor allowlists the depot by public source address and now sees a different one, so it refuses the session before authentication is reached. The backup overruns because a full site backup does not fit through a circuit sized for voice. Call quality degraded because the circuit bought to protect voice is now carrying the entire depot, so voice competes with the backup job for the same shaper. And the site-to-site tunnel never moved, because it is originated by the router rather than received on eth1, which is precisely the fact that convinced everyone the network was fine.

Remediation

Narrowing the prefix-list from /16 to /24 is a one-token edit and it is not the whole job. Before committing, confirm the primary circuit can take back the load it has not carried for eleven days, because the change moves the whole depot from one WAN to the other in a single commit and doing that into a saturated circuit converts a degraded site into a down one. Expect a wave of session resets at the cutover: long-lived flows established over the voice circuit change egress interface the moment the rule narrows, and their address translation was set up for the other circuit. Announce it, and prefer a quiet window - the site is degraded, not down, and holding until the evening with a named owner and an end time is a defensible choice that costs the finance team one more afternoon of the workaround they have already been using for nine days. Stage the edit, read it with `compare`, and commit under `commit-confirm` so a second transposed digit reverts itself. Then finish the parts that are easy to skip. Reverse the workarounds: if anyone added the voice circuit address to the SaaS allowlist to unblock finance, remove it, because a workaround left in place hides the next occurrence of exactly this failure. And record, rather than fix in the same change, the second defect the investigation exposed - the route-map hard-codes its next-hop with no tracking, so when the voice circuit fails, voice traffic will be handed to a dead gateway instead of failing over. That is a separate change with its own testing, and the incident review is the right place to raise it.

Verification

The test that matters is the one the original change did not run: query the routing decision for a source in every subnet the router serves, not only the subnet the policy was written for. `ip route get 203.0.113.50 from 10.10.0.5 iif eth1` must still resolve via the voice circuit, and the same query from a data, guest and server address must resolve via the primary ISP. Add the `iif` argument deliberately - without it the kernel answers for a locally-originated packet, and an interface-scoped rule does not apply to those, so the query quietly answers a different question than the one you meant to ask. Confirm the selector itself with `ip rule show`, which should now print the /24. Clear the route-map counters and watch the matching count grow at something like call volume rather than at site volume. Read the interface counters on both WANs after the link has carried real traffic for a while; the ratio between them is the shape of the fix. Then prove the reported symptoms rather than the router state: log in to the SaaS ERP from a data-VLAN workstation, query an external address-reflection service from that same workstation and confirm it reports the primary ISP address, place a test call, and run the backup job against real data rather than a token file. Finally confirm the IPsec tunnel to HQ is still up, because it is the one thing in this incident that was never wrong and the change had no business disturbing it.

Prevention

A match statement makes two claims - what it selects and what it leaves alone - and the acceptance test for this change only exercised the first. Placing a call and watching the packets leave the correct interface proves the policy catches what it was meant to catch, and can never detect a selector that is too broad. Every policy-routing change needs a negative case: one address from every subnet the router serves, resolved with `ip route get ... iif`, with the expected egress written down beforehand. That is a handful of lines and it belongs in the change record next to the positive test. Generate the prefix-lists that feed policy routing from the same source of truth as the interface addressing rather than typing them, because /16 and /24 are one keystroke apart and read identically at three in the morning. Monitor egress bytes per WAN as a ratio and alert on the ratio rather than on absolutes; a policy match that is too broad shows up as a step change in that ratio within minutes, which is the cheapest detector available for this entire failure class. Treat a hard-coded policy next-hop as incomplete until it has a tracking mechanism attached. And make reversing symptom-level workarounds part of closing the incident, because an allowlist entry added to unblock a user is indistinguishable from a permanent design decision six months later.

Reported symptoms

Three tickets, three queues, eleven days, and not one of them mentions the router.

The oldest is with the SaaS vendor. Nine days ago the finance team at the Northgate depot stopped being able to log in to the ERP. The page they get is an access-denied page rather than a wrong-password page, which the vendor’s first line read as a licensing problem and escalated accordingly. Finance have been working through the HQ VPN as a workaround, which is slow and which they are unhappy about.

The second is with the voice carrier. Call quality at Northgate has been worse since the voice-quality project completed than it was before the project started. Users describe choppy audio in the afternoons and during the evening. The carrier has been asked to investigate the new circuit and has so far reported no faults on it.

The third is with the backup team. The nightly off-site backup from the depot has overrun its window and been killed by the scheduler on six of the last eleven nights. It was reliable for two years before that.

Everything else at the site is normal. Browsing is fine, video calls to HQ are fine, and the site-to-site IPsec tunnel to HQ has not flapped once. That last fact has been cited in all three tickets as evidence that the depot’s connectivity is healthy.

One change touched the depot router in three months. Eleven days ago a policy route was added to send voice traffic out the new low-latency circuit. It was tested by placing a call and watching the packets leave the correct interface. It worked. The change was closed the same evening.

The estate

rtr-edge-1 is the VyOS 1.5 LTS router at the Northgate depot.

  • eth0 faces the primary ISP. This is the circuit the depot has always used, it is the circuit whose public address the SaaS vendor allowlisted three years ago, and it is the source of the IPsec tunnel to HQ.
  • eth3 faces the new circuit: 50 Mbit, low latency, bought for voice and sized for a few dozen concurrent calls. Its next-hop is 198.51.100.1.
  • eth1 is the transit link to the campus layer-3 switch. Every depot subnet sits behind that switch and arrives at the router on this one interface: 10.10.0.0/24 voice, 10.10.20.0/24 data, 10.10.30.0/24 guest, 10.10.50.0/24 servers, plus a CCTV range.

Both WANs are source-NAT’d behind their own public address. The policy route added eleven days ago consists of a prefix-list naming the voice sources, a route-map that sets the voice circuit as the next-hop for anything matching it, and a binding of that route-map to eth1.

Evidence provided

Read-only / Safethis line has been on the screen of anyone who looked, for eleven days
vyos@rtr-edge-1:~$ ip rule show
0:      from all lookup local
100:    from 10.10.0.0/16 iif eth1 lookup 100
32766:  from all lookup main
32767:  from all lookup default

Illustrative output

Read-only / Safeone route: everything matching the rule leaves by the voice circuit
vyos@rtr-edge-1:~$ ip route show table 100
default via 198.51.100.1 dev eth3

Illustrative output

Read-only / Safea voice source takes the voice circuit - the change did what it promised
vyos@rtr-edge-1:~$ ip route get 203.0.113.50 from 10.10.0.5 iif eth1
203.0.113.50 from 10.10.0.5 via 198.51.100.1 dev eth3 table 100

Illustrative output

Read-only / Safe10.10.20.44 is a finance workstation, and it takes the voice circuit too
vyos@rtr-edge-1:~$ ip route get 203.0.113.50 from 10.10.20.44 iif eth1
203.0.113.50 from 10.10.20.44 via 198.51.100.1 dev eth3 table 100

Illustrative output

Read-only / Safedata-VLAN traffic on a circuit bought for voice
vyos@rtr-edge-1:~$ tcpdump -ni eth3 'src net 10.10.20.0/24' -c 5
14:07:11.882 IP 10.10.20.44.51338 > 203.0.113.50.443: Flags [S], length 0
14:07:11.914 IP 10.10.20.17.44902 > 203.0.113.50.443: Flags [S], length 0
14:07:12.003 IP 10.10.20.44.51339 > 203.0.113.50.443: Flags [S], length 0

Illustrative output

The route-map counter reads three orders of magnitude above what the depot’s call volume could produce. eth3 sits at its shaper ceiling every night during the backup window. eth0 is close to idle apart from the IPsec tunnel.

Work the evidence before reading on

Nothing here is failing. Every interface is up, every circuit is carrying traffic, and the router has no error to report.

  1. Read the ip rule output and the intent side by side. The voice VLAN is 10.10.0.0/24. What does the rule say, and how many of the depot’s subnets does that cover?
  2. The change was tested and the test passed. Work out precisely what the test proved, and then what it could not have proved no matter how carefully it was run.
  3. Three tickets: a SaaS login refused before authentication, a backup that no longer fits its window, and voice that got worse after a voice project. What single property of a packet, if it changed, produces all three?
  4. The IPsec tunnel never moved, and everyone has treated that as evidence of health. Look at the selector in the rule and work out why the tunnel is exempt. Is that reassuring or is it the most informative line in the output?
  5. The router is behaving correctly and no component has failed. What does that tell you about which teams can possibly find this, and how long they will look before they do?

Before continuing: a selector is a statement about two sets, and only one of them was ever tested.

Root cause

1. One character of prefix length

The prefix-list feeding the route-map holds 10.10.0.0/16. The voice VLAN is 10.10.0.0/24.

In a configuration diff those two lines are nearly indistinguishable. As selectors they are not comparable at all. The /24 covers 254 addresses: the phones, and nothing else. The /16 covers 65,534, which at this depot means the data VLAN, the guest VLAN, the server subnet and the cameras.

Every one of those subnets arrives at the router on eth1, from the campus switch, so every one of them satisfies both halves of the rule selector — the source prefix and the input interface. Since eleven days ago, the entire depot has been egressing through a 50 Mbit circuit contracted to carry a few dozen calls.

2. Why the test could not have caught it

The change was tested. The engineer placed a call, captured on the voice circuit, saw the phone’s packets, and closed the ticket. That is a correct test, competently executed, and it proves the policy catches what it was meant to catch.

It cannot prove anything about what the policy leaves alone, because it never sends a packet that the policy is supposed to ignore. A selector that is too broad passes this test perfectly. So does a selector that is exactly right. The test does not distinguish them, and no amount of care in running it would change that.

This is the whole lesson of the file, and it generalises far beyond policy routing: whenever you write a match — a firewall rule, a prefix-list, a route-map, a NAT rule — you have made two claims, and the obvious test only exercises one of them.

3. Three symptoms, one changed egress

Nothing downstream of the router knows anything about routing policy. What they see is a changed source address and a changed amount of bandwidth, and each ticket is one consequence of that.

The SaaS vendor allowlists the depot by public source address. Traffic now leaves via the other circuit and is translated behind the other public address, so the vendor rejects the session before it reaches authentication — which is exactly why the error is an access-denied page and not a password error. The backup job does not fit through a voice-sized circuit and overruns. And call quality degraded because the circuit bought to isolate voice from everything else is now carrying everything else, so voice competes with a nightly backup for the same shaper. The project achieved the precise opposite of its goal.

Resolution

  1. Establish the intended selector from the addressing plan, not from the rule. The voice VLAN is a /24; confirm that against the interface configuration on the campus switch rather than against the document that the prefix-list was typed from.
  2. Check that the primary circuit can take the load back before you move it. The depot has been egressing through the voice circuit for eleven days, so the primary has no recent load history. Moving a saturated volume onto a circuit you have not sized turns a degraded site into a down one.
  3. Decide the cutover time deliberately. Narrowing the selector moves the whole depot between WANs in one commit, and long-lived flows established over the voice circuit will change egress with their address translation already set up for the other one. Expect resets. The site is degraded rather than down, so an evening window is a legitimate choice - with an owner, an end time, and a note on all three tickets.
  4. Stage the corrected prefix-list without committing, and read the change with compare. The failure you are fixing is a mistyped prefix length, so the diff is the last cheap chance to notice a second one.
  5. Commit under commit-confirm with a short window. This is an egress-path change on a remote site router; if the new selector is also wrong, you want the router to undo it without you.
  6. Watch the traffic move before confirming. Egress bytes should shift from the voice circuit to the primary within seconds, and the voice circuit should settle at something like call volume.
  7. Reverse the workarounds. If the voice circuit address was added to the SaaS allowlist to unblock finance, remove it and re-test - an allowlist entry left behind hides the next occurrence of this exact failure.
  8. Record the untracked next-hop as a separate change rather than fixing it here. The route-map hard-codes its next-hop with no tracking, so a voice circuit failure will hand voice traffic to a dead gateway instead of failing over. That deserves its own testing and its own window.

Verification

  1. Run the negative case, which is the test the original change never ran. Resolve one address from every subnet the router serves: ip route get 203.0.113.50 from ADDR iif eth1. The voice source must resolve via the voice circuit; data, guest, server and camera sources must resolve via the primary ISP.
  2. Include the iif argument on purpose. Without it the kernel answers for a locally-originated packet, and an interface-scoped rule does not apply to those, so the query answers a different question than the one you meant to ask and can return a reassuring wrong answer.
  3. Confirm the selector itself. ip rule show prints the /24 rather than the /16, and there is exactly one policy rule rather than two.
  4. Clear the route-map counters and let real traffic run. The matching count should now grow at something like call volume. A counter still climbing at site volume means the selector is still wrong.
  5. Read the WAN counters as a ratio after the link has carried real traffic for a while. The proportion between the two circuits is the shape of the fix, and it is the number worth alerting on afterwards.
  6. Prove the tickets, not the router. Log in to the SaaS ERP from a data-VLAN workstation. Query an external address-reflection service from that same workstation and confirm it reports the primary ISP address. Place a test call. Run the backup against real data rather than a token file.
  7. Confirm the IPsec tunnel to HQ is still up. It was the one thing in this incident that was never wrong, and the fix had no business disturbing it.

Prevention

  • Write the negative case into the change template. One address per subnet the router serves, resolved with ip route get ... iif, with the expected egress recorded before the change is made. It is a handful of lines, it takes a minute, and it is the only test that can distinguish a correct selector from an over-broad one.
  • Generate policy prefix-lists from the addressing source of truth. A /16 and a /24 are one keystroke apart and read identically to a tired reviewer. A generated list cannot make this mistake; a typed one will eventually.
  • Alert on the ratio of egress bytes between WANs, not on the absolutes. A selector that is too broad produces a step change in that ratio within minutes of the commit. This is the cheapest possible detector for the entire failure class, and it works even when nothing has failed.
  • Treat a hard-coded policy next-hop as incomplete until it is tracked. Without tracking, the policy quietly becomes a black hole the moment its circuit fails, and the traffic class it was written to protect is the one that disappears.
  • Reverse symptom-level workarounds as part of closing the incident. An allowlist entry added to unblock a user is indistinguishable from a deliberate design decision six months later, and it will hide the recurrence.
  • Record the intent of every policy rule in its description. The next person to read this configuration in an incident has no way to know whether the /16 was a mistake or a deliberate choice made for a reason nobody wrote down.