Ipsec: vti-up-down DB keyed by connection name - teardown of a stale IKE_SA admin-downs a VTI still carried by a live SA

198.51.100.10 = CPE WAN, 203.0.113.11 = OCI tunnel 1 headend, 203.0.113.22 = OCI tunnel 2 headend.

Environment

VyOS version 1.5 built from source at vyos-build 0ba023e
Affected files src/etc/ipsec.d/vti-up-down, python/vyos/utils/vti_updown_db.py
Topology 2× IPsec site-to-site (IKEv2, PSK), route-based, bound to vti0 / vti1, eBGP over the VTIs

The vti_updown_db.py on the affected system is byte-identical to upstream at e1fd60f8e(blob 0587b48e861683ff4083d647fbd1882039a6d4ff).

The key has been unchanged since the file was introduced and is present in every revision of it to date:

date commit ifspec key container
2024-07-03 376e2d898 — T5873, ipsec updown hook rewrite {interface}:{connection}:{protocol} set
2025-06-28 1478516ae unchanged set
2025-11-28 e1fd60f8e — T8001 unchanged set
2026-03-20 bb2aee1e5 unchanged set
2026-06-18 368b0468b — T8975, DB locking unchanged set
2026-06-29 094928ba0 unchanged set

Confirmed still present in rolling @ 14c596644.

Summary

When two IKE_SAs of the same connection exist simultaneously - which happens routinely whenever the peer re-establishes a tunnel before the local side has finished timing the old SA out - the up/down state database cannot represent them separately. The teardown of the old, dead SA removes the database entry that the new, live SA depends on, and the VTI is put admin-down while its CHILD_SA remains INSTALLED.

The interface never comes back on its own: the surviving SA already ran its up-client hook, so nothing re-adds the entry. Everything downstream of the VTI (here: eBGP) stays down until an operator intervenes.

Impact

Silent, total loss of traffic over the affected VTI, with no error anywhere and no self-recovery.

The failure is particularly hostile to diagnose because every status command reports health:

$ show vpn ipsec sa
Connection    State    Uptime    Bytes In/Out    Packets In/Out    Remote address
------------  -------  --------  --------------  ----------------  ----------------
OCI-T1-vti    up       1m51s     0B/0B           0/0               203.0.113.11
OCI-T2-vti    up       2m4s      0B/0B           0/0               203.0.113.22

$ show interfaces vti
Interface        IP Address                        S/L  Description
---------        ----------                        ---  -----------
vti0             169.254.150.122/30                A/D  OCI-T1
vti1             169.254.189.46/30                 A/D  OCI-T2

IPsec is genuinely healthy - DPD every 10 s, CHILD_SA rekeys succeeding on schedule - while the interface underneath is administratively down. In our case this produced a 4.5-hour outage before it was noticed.

Recovery is sudo ip link set vti0 up (or reset vpn ipsec), but the workaround leaves the database inconsistent: the interface is up but has no entry.

Root cause

vti-up-down identifies a connection solely by PLUTO_CONNECTION:

# src/etc/ipsec.d/vti-up-down:46-47
verb = os.getenv('PLUTO_VERB')
connection = os.getenv('PLUTO_CONNECTION')
...
db.add(interface, connection, protocol)      # line 62
db.remove(interface, connection, protocol)   # line 66

and VTIUpDownDB derives its key from that name alone, storing keys in a set:

# python/vyos/utils/vti_updown_db.py
self._ifspecs = set(...)                                       # line 102
ifspec = f"{interface}:{connection}:{protocol}"                # lines 116, 128

Two concurrent IKE_SAs of the same connection therefore produce the byte-identical key vti1:OCI-T2-vti:v4. Because the container is a set, multiplicity is impossible:

  • add() for the second SA finds the key already present → complete no-op. The interface is not even added to _ifsUp, which is why no Interface ... is admin up line is ever logged for it.
  • remove() for the first SA deletes the only copy → interface_remains evaluates False_ifsDownip link set <if> down.

The database is effectively a set of connection names, but it is used as if it were a reference count over security associations.

Evidence

Real-world sequence, from charon.log. The peer restarted its VPN headend, which caused it to initiate a fresh IKE_SA rather than rekey the existing one, while the local side was still working through the DPD retransmit sequence for the old SA:

10:26:10  OCI-T2|1802  sending DPD request                      # old SA, peer has gone away
10:26:14  OCI-T2|1802  retransmit 1 of request with message ID 12
...
10:27:08  203.0.113.22 is initiating an IKE_SA                  # peer builds a NEW SA
10:27:08  IKE_SA OCI-T2[1813] established
10:27:08  CHILD_SA OCI-T2-vti{2888} established with SPIs cefa7470_i 63bf7620_o
10:27:08  vti-up-down: Interface vti1 up-client OCI-T2-vti      # <-- add() is a NO-OP; note the
                                                                #     absence of "is admin up"
10:28:55  OCI-T2|1802  giving up after 5 retransmits            # OLD SA finally dies
10:28:55  OCI-T2|1802  restarting CHILD_SA OCI-T2-vti
10:28:55  vti-up-down: Interface vti1 down-client OCI-T2-vti
10:28:55  vti-up-down: Interface vti1 is admin down             # <-- kills the LIVE SA's interface

(The same sequence played out on vti0 / OCI-T1 at 10:27:16 and 10:30:15.)

The two SAs overlapped for 107 seconds. Afterwards the surviving SA stayed perfectly healthy for hours - subsequent CHILD_SA rekeys show inbound bytes arriving and zero outbound, the signature of an admin-down interface:

14:18:34  closing CHILD_SA OCI-T1-vti{2897} with SPIs ce69c190_i (4140 bytes) de0b68c9_o (0 bytes)
15:02:42  closing CHILD_SA OCI-T2-vti{2898} with SPIs ca6336f9_i (3780 bytes) ab06cd8d_o (0 bytes)
sequenceDiagram
    participant Old as IKE_SA #1802 (stale)
    participant New as IKE_SA #1813 (live)
    participant DB as vti_updown_db
    participant VTI as vti1

    Note over DB: {vti1:OCI-T2-vti:v4}
    Note over Old: peer gone, DPD retransmitting
    New->>DB: up-client OCI-T2-vti
    Note over DB: key already present → NO-OP
    Note over VTI: still up (by luck, not by bookkeeping)
    Note over Old: giving up after 5 retransmits
    Old->>DB: down-client OCI-T2-vti
    Note over DB: { } — empty
    DB->>VTI: ip link set vti1 down
    Note over New,VTI: CHILD_SA INSTALLED, interface DOWN, no recovery path

Reproduction

The existing suite (src/tests/test_vti_updown_db.py::test_multiple_connections_same_interface) covers only distinct connection names, which works correctly. The same-name case is untested. This test fails on rolling today:

def test_same_connection_multiple_sas(self):
    """A connection with two concurrent IKE_SAs must keep the interface up
    until *both* have gone away."""
    from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update

    with patch('vyos.utils.vti_updown_db.Lock', MagicMock()):
        with open_vti_updown_db_for_create_or_update() as db:
            db.add('vti0', 'conn-a', 'IKEv2')       # SA #1
            db.add('vti0', 'conn-a', 'IKEv2')       # SA #2, established before #1 timed out
            db.remove('vti0', 'conn-a', 'IKEv2')    # SA #1 dies
            # SA #2 is still INSTALLED, so the interface must stay up
            self.assertTrue(db.wantsInterfaceUp('vti0'))

To reproduce on real hardware: bring up a route-based site-to-site tunnel, then make the peer initiate a new IKE_SA while the local side is mid-DPD-timeout on the old one (blackholing the peer’s return path for ~30 s and letting it reconnect is sufficient). The VTI drops when the old SA expires and stays down.

Proposed fix

Include the IKE_SA unique identifier in the key. strongSwan’s updown plugin already exports it - PLUTO_UNIQUEID, documented as “the unique identifier of the associated IKE_SA”:

--- a/src/etc/ipsec.d/vti-up-down
+++ b/src/etc/ipsec.d/vti-up-down
@@
     verb = os.getenv('PLUTO_VERB')
     connection = os.getenv('PLUTO_CONNECTION')
+    uniqueid = os.getenv('PLUTO_UNIQUEID')
     interface = sys.argv[1]
@@
-            db.add(interface, connection, protocol)
+            db.add(interface, connection, protocol, uniqueid)
@@
-            db.remove(interface, connection, protocol)
+            db.remove(interface, connection, protocol, uniqueid)

--- a/python/vyos/utils/vti_updown_db.py
+++ b/python/vyos/utils/vti_updown_db.py
@@
-        ifspec = f"{interface}:{connection}:{protocol}" if (...) else interface
+        ifspec = f"{interface}:{connection}:{protocol}:{uniqueid}" if (...) else interface

All readers extract the interface with ifspec.split(':')[0], which is unaffected by a fourth field, and persistent entries (those containing no
:) are untouched. removeAllOtherInterfaces() and setPersistentInterfaces() need no changes.

Caveat

Keying on the SA identity means a lost down event leaks an entry and the interface stays up indefinitely, where today it would (eventually, incorrectly) go down. This may be a safer failure mode than dropping an interface that is carrying traffic, but it argues for reconciling the database against swanctl --list-sas on commit, which could be handled separately.

Workaround

Bring the interface back manually — the SA is already installed, so no IPsec-level action is needed:

sudo ip link set vti0 up

Hi @rhinok239 , the bug report created ⚓ T9154 IPsec vti-up-down: DB keyed only by connection name causes admin-down of a VTI still carried by a live IKE_SA

Do you want to claim it and add a PR?

Hi @Viacheslav, added 5370.