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 noInterface ... is admin upline is ever logged for it.remove()for the first SA deletes the only copy →interface_remainsevaluatesFalse→_ifsDown→ip 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