Page MenuHomeVyOS Platform

GRE tunnel key uniqueness check ignores remote address and source-interface
In progress, NormalPublicBUG

Description

Summary

The pairwise GRE tunnel collision check in src/conf_mode/interfaces_tunnel.py is more
restrictive than the Kernel when a GRE key is configured. Configuring
parameters ip key currently makes a configuration more likely to be rejected than
leaving it out, which is the opposite of what the check is meant to achieve.

When no key is set, the check correctly requires the remote addresses to match before it
complains (if our_remote == their_remote:, added by T4267). The keyed branch never got
the same treatment — it only compares source-address and key:

if our_key != None:
    if their_address == our_address and their_key == our_key:
        raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" '
                          f'is already used for tunnel "{o_tunnel}"!')

remote and source_interface are not even in scope there — they are only looked up
inside the else: branch. This code is unchanged since T2920 (2020).

Steps to reproduce

Two GRE tunnels from the same source to different destinations, sharing one key:

set interfaces tunnel tun10 encapsulation 'gre'
set interfaces tunnel tun10 source-address '192.0.2.1'
set interfaces tunnel tun10 remote '203.0.113.1'
set interfaces tunnel tun10 parameters ip key '10'

set interfaces tunnel tun20 encapsulation 'gre'
set interfaces tunnel tun20 source-address '192.0.2.1'
set interfaces tunnel tun20 remote '203.0.113.2'
set interfaces tunnel tun20 parameters ip key '10'

Expected behaviour

The commit succeeds. The two tunnels differ in remote address, so they are distinct for
the Kernel.

Actual behaviour

Key "10" for source-address "192.0.2.1" is already used for tunnel "tun10"!

The identical configuration without parameters ip key commits fine.

A second variant of the same defect: two tunnels bound to different source-interfaces
sharing one key are rejected, because both have no source-address and None == None
compares equal:

set interfaces tunnel tun10 encapsulation 'gre'
set interfaces tunnel tun10 source-interface 'eth0'
set interfaces tunnel tun10 parameters ip key '10'

set interfaces tunnel tun20 encapsulation 'gre'
set interfaces tunnel tun20 source-interface 'eth1'
set interfaces tunnel tun20 parameters ip key '10'

Why this is a VyOS restriction and not a Kernel one

ip_tunnel_find() in net/ipv4/ip_tunnel.c treats a tunnel as a duplicate only when
all five of these match:

local  == t->parms.iph.saddr &&
remote == t->parms.iph.daddr &&
link   == READ_ONCE(t->parms.link) &&      /* the source-interface */
type   == t->dev->type &&
ip_tunnel_key_match(&t->parms, flags, key)

ip_tunnel_newlink() returns -EEXIST only on that full-tuple match. On the receive
side ip_tunnel_lookup() compares local and remote before it looks at the key,
and uses parms.link as a tie-break, so the tunnels also demultiplex unambiguously.

Verified empirically on Linux 6.12 / iproute2 6.15, in a network namespace:

# ip tunnel add gre1 mode gre local 192.0.2.1 remote 192.0.2.10 key 10   -> OK
# ip tunnel add gre2 mode gre local 192.0.2.1 remote 192.0.2.11 key 10   -> OK
# ip tunnel add gre3 mode gre dev dum0 key 20                            -> OK
# ip tunnel add gre4 mode gre dev dum1 key 20                            -> OK

Both pairs are rejected by VyOS today.

Related, found while testing: 0.0.0.0 is not treated as "unset"

The Kernel stores an unconfigured endpoint as the any address, so remote 0.0.0.0 and
an unset remote are the same tunnel — likewise for source-address. VyOS compares the
raw strings, so this pair is accepted and then fails at commit time with
add tunnel "gre0" failed: File exists:

set interfaces tunnel tun10 encapsulation 'gre'
set interfaces tunnel tun10 source-address '192.0.2.1'
set interfaces tunnel tun10 source-interface 'eth0'
set interfaces tunnel tun10 remote '203.0.113.1'
set interfaces tunnel tun10 parameters ip key '10'

set interfaces tunnel tun20 encapsulation 'gre'
set interfaces tunnel tun20 source-address '0.0.0.0'
set interfaces tunnel tun20 source-interface 'eth0'
set interfaces tunnel tun20 remote '203.0.113.1'
set interfaces tunnel tun20 parameters ip key '10'

Confirmed on 6.12: the second ip tunnel add returns File exists.

Proposed fix

Compare the same tuple the Kernel does: normalise 0.0.0.0/:: to "unset", skip the
peer when the remote addresses differ, and make the keyed branch consider
source-interface as well as source-address.

Deliberately not relaxed:

  • same local + same remote + no key, whichever source-interface is used — the Kernel will create these, but they are byte-identical on the wire outbound and ip_tunnel_lookup() resolves them non-deterministically for traffic arriving on a third interface;
  • two mGRE tunnels on one source-interface sharing a key (DMVPN) — still rejected;
  • gre vs gretap are still compared with each other, although the Kernel keeps them in separate tunnel tables. Worth a separate task.

Compatibility impact

Behaviour change, no migration script required — there are no CLI syntax changes.

The relaxations are purely additive: configurations that were previously rejected now
commit, and nothing that previously worked stops working.

The 0.0.0.0 normalisation is the one case that goes the other way. It newly rejects, at
commit time, two GRE tunnels sharing a source address and key where one sets
remote 0.0.0.0 and the other omits remote. That configuration already failed to apply
with add tunnel "gre0" failed: File exists, so no working configuration changes
behaviour — the failure simply moves from apply time to commit time, where it can be
reported properly.

Version

Reproduced by reading rolling (b361f499e). The keyed branch is unchanged since 2020,
so every release from 1.2 onwards is affected.

This is not a duplicate of T4267

T4267 fixed the keyless branch — "same source-address, different remote, no key" has
committed cleanly since 1.4.0, and test_multiple_gre_tunnel_different_remote covers it.
The keyed branch never received the same treatment, which is what this task is about.
(Note for anyone arriving here from a search: if you hit
Missing required "ip key" parameter ... on the same source-address with no key set
and differing remotes, you are on 1.3.x or older and want T4267 — upgrade to 1.4+.)

Prior art

  • T2920 — introduced the keyed check being fixed here
  • T4154 — introduced the keyless requirement
  • T4267 — added the remote comparison, but only to the keyless branch
  • T6157 — fixed unset values comparing equal in the keyless branch

Details

Version
1.5-rolling (2026.07.21)
Is it a breaking change?
Behavior change
Issue type
Bug (incorrect behavior)

Event Timeline

Viacheslav changed the task status from Open to In progress.Aug 11 2026, 3:09 PM
Viacheslav assigned this task to lclements0.
Viacheslav triaged this task as Normal priority.Aug 11 2026, 3:18 PM

Three changes came out of review and testing on PR #5392. Two of them narrow or widen what verify() accepts relative to the original description above, so the rules listed there are superseded by the list at the end of this comment.

1. A keyless tunnel no longer collides with a keyed one

alexk37 on GitHub found a real regression while testing the branch: a keyless tunnel and a keyed tunnel sharing one source-address both committed and both came up, but every later commit that re-ran the keyless tunnel's script was rejected, and after a reboot that tunnel was silently gone from both the Kernel and the running config.

set interfaces tunnel tun10 encapsulation gre
set interfaces tunnel tun10 source-address 192.0.2.1

set interfaces tunnel tun20 encapsulation gre
set interfaces tunnel tun20 source-address 192.0.2.1
set interfaces tunnel tun20 remote 0.0.0.0
set interfaces tunnel tun20 parameters ip key 10

The keyless branch never looked at the neighbour's key, and the 0.0.0.0 normalisation introduced here is what made that path reachable for a config current rolling accepts. The Kernel only ever matches a keyless tunnel against another keyless tunnel — see ip_tunnel_key_match() in include/net/ip_tunnels.h — so a genuinely keyed neighbour is now skipped outright.

2. A zero key does not count as a key

parameters ip key 0 is CLI-valid, but it does not buy a tunnel any uniqueness on receive: the IPv4 lookup ends in a flag-blind compare in ip_tunnel_lookup(), and ip6gre_tunnel_locate() compares i_key with no flag gate at all. So keyless and key 0 stay ambiguous inbound, and a zero-keyed neighbour stays in scope for the keyless check. This restores what rolling did before change 1 above.

3. Keyless tunnels on different source-interfaces are now accepted

This reverses the rule in the description that keyless tunnels sharing a local and remote address should still be rejected when their source-interfaces differ.

That rule was wrong. source-interface reaches the Kernel as dev, so it lands in the tunnel's link index, which ip_tunnel_find() compares alongside local address, remote address, tunnel type and key. Two such tunnels are genuinely distinct devices, and the inbound demultiplexing concern that motivated the original rule does not apply — the link index discriminates.

Measured on kernel 6.12 / iproute2 6.15 in a network namespace:

  • two keyless gre tunnels, same local and remote, bound to different dummy interfaces → both create;
  • the same pair bound to one interface → second add fails with add tunnel "gre0" failed: File exists.

Resulting rules

For two tunnels in the GRE family sharing a normalised remote address, where 0.0.0.0 and :: both normalise to unset:

  • rejected — both keyless, same local address and same source-interface;
  • rejected — both keyed, same local address, same source-interface and the same key (mGRE tunnels sharing a key on one source-interface);
  • rejected — one keyless and one carrying key 0, same local address and same source-interface;
  • accepted — differing remote addresses, whether keyed or not;
  • accepted — same key, differing local addresses or differing source-interfaces;
  • accepted — keyless alongside a tunnel carrying a non-zero key.

Unchanged from the original description: gre and gretap are still compared against each other even though the Kernel keeps them in separate tunnel tables, and ip6gre/ip6gretap are still not validated. Both remain deliberate follow-ups for their own tasks rather than scope for this one.

Each case above is covered by a smoketest in smoketest/scripts/cli/test_interfaces_tunnel.py; the suite passes in CI at 42 tests.

Correction - change 2 above was too broad, and rule 3 in the resulting list with it.

alexk37 tested the branch again and showed that the reasoning behind "a zero key
does not count as a key" does not hold in general. Both citations in that
paragraph were wrong for the rule they supported:

  • The flag-blind compare in ip_tunnel_lookup() is only its last loop, and that loop is reached only by tunnels with saddr == 0 && daddr == 0. A tunnel carrying a local or a remote address is matched in one of the three loops above it, through ip_tunnel_key_match(), which tests the flag saying that a key is set before it compares the value - so there a zero key and an unset key are two different tunnels.
  • ip6gre_tunnel_locate() is the create path of a protocol this check never reaches: the pairwise check runs for gre and gretap only. It stays relevant for the deferred ip6gre task, not for this one.

Measured on kernel 6.12 / iproute2 6.15 in a network namespace, sending GRE
packets in from a veth peer and counting per-tunnel rx:

t1 keyless, t2 key 0, both local 10.0.0.1
  packet with no key -> t1 +3   t2 +0
  packet with key 0  -> t1 +0   t2 +3

t1 keyless, t2 key 0, neither a local nor a remote
  packet with no key -> t1 +0   t2 +3
  packet with key 0  -> t1 +0   t2 +3

So parameters ip key 0 does make a tunnel unique wherever an endpoint is set,
and only fails to where neither tunnel carries one - the single shape that
reaches that last loop.

Amended rule. Replace the third entry of the resulting-rules list with these
two:

  • rejected - one keyless and one carrying key 0, same source-interface, where neither tunnel carries a local or a remote address;
  • accepted - one keyless and one carrying key 0, where either of them carries a local or a remote address.

The other five entries are unchanged and still hold. Change 1 above reads a
little more precisely now as well: the neighbour skipped outright by the keyless
check is one carrying a key which identifies it, and a zero key does that only
where an endpoint is set.

A further change, in the single-tunnel check. The rule that a gre tunnel
with source-address 0.0.0.0 needs a key read the configured value directly, so
key 0 satisfied it while the pairwise check treated the same value as no key at
all - the file gave two answers to one question. It now uses the same
classification: with no remote either, such a tunnel needs a non-zero key,
because with key 0 it is the same catch-all a keyless tunnel would be, which is
what that check has always refused. A tunnel which does carry a remote is unique
without its key, so key 0 remains acceptable there.

This is the only place the series narrows rather than widens what commits, so it
is kept as its own commit and can be lifted out if it is better handled under a
separate task.

Coverage. The suite is 43 tests with the one added here, rather than the 42
stated above - test_multiple_gre_tunnel_zero_key_with_endpoints covers the
newly accepted pairs, test_multiple_gre_tunnel_zero_key still covers the pair
which stays rejected, and test_tunnel_src_any_gre_key covers the single-tunnel
rule. The last completed CI run reported 42 on the previous head; the run for
this push is still queued behind the approval gate that fork pull requests hit.