Skip to content

Commit

Permalink
T5873: Re-write of ipsec updown hook to support remote access VPN
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasec committed Jul 5, 2024
1 parent f321908 commit c536878
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 32 deletions.
19 changes: 17 additions & 2 deletions python/vyos/ifconfig/vti.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from vyos.ifconfig.interface import Interface
from vyos.utils.dict import dict_search
from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_readonly

@Interface.register
class VTIIf(Interface):
Expand All @@ -27,6 +28,10 @@ class VTIIf(Interface):
},
}

def __init__(self, ifname, **kwargs):
self.bypass_vti_updown_db = kwargs.pop("bypass_vti_updown_db", False)
super().__init__(ifname, **kwargs)

def _create(self):
# This table represents a mapping from VyOS internal config dict to
# arguments used by iproute2. For more information please refer to:
Expand Down Expand Up @@ -57,8 +62,18 @@ def _create(self):
self.set_interface('admin_state', 'down')

def set_admin_state(self, state):
""" Handled outside by /etc/ipsec.d/vti-up-down """
pass
"""
Set interface administrative state to be 'up' or 'down'.
The interface will only be brought 'up' if ith is attached to an
active ipsec site-to-site connection or remote access connection.
"""
if state == 'down' or self.bypass_vti_updown_db:
super().set_admin_state(state)
elif vti_updown_db_exists():
with open_vti_updown_db_readonly() as db:
if db.wantsInterfaceUp(self.ifname):
super().set_admin_state(state)

def get_mac(self):
""" Get a synthetic MAC address. """
Expand Down
149 changes: 149 additions & 0 deletions python/vyos/utils/vti_updown_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Copyright 2024 VyOS maintainers and contributors <[email protected]>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.

import os

from contextlib import contextmanager
from syslog import syslog

VTI_WANT_UP_IFLIST = '/tmp/ipsec_vti_interfaces'

def vti_updown_db_exists():
return os.path.exists(VTI_WANT_UP_IFLIST)

@contextmanager
def open_vti_updown_db_for_create_or_update():
if vti_updown_db_exists():
f = open(VTI_WANT_UP_IFLIST, 'r+')
else:
f = open(VTI_WANT_UP_IFLIST, 'x+')
try:
db = VTIUpDownDB(f)
yield db
finally:
f.close()

@contextmanager
def open_vti_updown_db_for_update():
f = open(VTI_WANT_UP_IFLIST, 'r+')
try:
db = VTIUpDownDB(f)
yield db
finally:
f.close()

@contextmanager
def open_vti_updown_db_readonly():
f = open(VTI_WANT_UP_IFLIST, 'r')
try:
db = VTIUpDownDB(f)
yield db
finally:
f.close()

def remove_vti_updown_db():
# We need to process the DB first to bring down any interfaces still up
with open_vti_updown_db_for_update() as db:
db.syncInterfaces([])
db.commit()

os.unlink(VTI_WANT_UP_IFLIST)

class VTIUpDownDB:
def __init__(self, f):
self._fileHandle = f
self._ifspecs = set([entry.strip() for entry in f.read().split(" ") if entry and not entry.isspace()])
self._ifsUp = set()
self._ifsDown = set()

def syncInterfaces(self, interface_list):
updated_ifspecs = set([ifspec for ifspec in self._ifspecs if ifspec.split(':')[0] in interface_list])
removed_ifspecs = self._ifspecs - updated_ifspecs
self._ifspecs = updated_ifspecs
interfaces_to_bring_down = [ifspec.split(':')[0] for ifspec in removed_ifspecs]
self._ifsDown.update(interfaces_to_bring_down)
self._ifsUp.difference_update(interfaces_to_bring_down)

def syncInterfacesAlwaysUp(self, interface_list):
new_unconditional_interfaces = set(interface_list)
current_unconditional_interfaces = set([ifspec for ifspec in self._ifspecs if ':' not in ifspec])
added_unconditional_interfaces = new_unconditional_interfaces - current_unconditional_interfaces
removed_unconditional_interfaces = current_unconditional_interfaces - new_unconditional_interfaces

for interface in added_unconditional_interfaces:
self.add(interface)

for interface in removed_unconditional_interfaces:
self.remove(interface)

def add(self, interface, connection = None, protocol = None):
ifspec = "{}:{}:{}".format(interface, connection, protocol) if (connection is not None and protocol is not None) else interface
if ifspec not in self._ifspecs:
self._ifspecs.add(ifspec)
self._ifsUp.add(interface)
self._ifsDown.discard(interface)

def remove(self, interface, connection = None, protocol = None):
ifspec = "{}:{}:{}".format(interface, connection, protocol) if (connection is not None and protocol is not None) else interface
if ifspec in self._ifspecs:
self._ifspecs.remove(ifspec)
interface_remains = False
for ifspec in self._ifspecs:
if ifspec.split(':')[0] == interface:
interface_remains = True

if not interface_remains:
self._ifsDown.add(interface)
self._ifsUp.discard(interface)

def wantsInterfaceUp(self, interface):
for ifspec in self._ifspecs:
if ifspec.split(':')[0] == interface:
return True

return False

def commit(self):
from vyos.configquery import ConfigTreeQuery
from vyos.configdict import get_interface_dict
from vyos.ifconfig import VTIIf
from vyos.utils.process import call
from vyos.utils.network import get_interface_config

self._fileHandle.seek(0)
self._fileHandle.write(' '.join(self._ifspecs))
self._fileHandle.truncate()

for interface in self._ifsDown:
vti_link = get_interface_config(interface)
vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False)
if vti_link_up:
call(f'sudo ip link set {interface} down')
syslog(f'Interface {interface} is admin down ...')

self._ifsDown.clear()

for interface in self._ifsUp:
vti_link = get_interface_config(interface)
vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False)
if not vti_link_up:
conf = ConfigTreeQuery()
_, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface)
if 'disable' not in vti:
tmp = VTIIf(interface, bypass_vti_updown_db = True)
tmp.update(vti)
call(f'sudo ip link set {interface} up')

self._ifsUp.clear()
19 changes: 16 additions & 3 deletions smoketest/scripts/cli/test_vpn_ipsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from base_vyostest_shim import VyOSUnitTestSHIM

from vyos.configsession import ConfigSessionError
from vyos.ifconfig import Interface
from vyos.utils.process import process_named_running
from vyos.utils.file import read_file

Expand Down Expand Up @@ -140,6 +141,7 @@ def tearDown(self):

self.cli_delete(base_path)
self.cli_delete(tunnel_path)
self.cli_delete(vti_path)
self.cli_commit()

# Check for no longer running process
Expand Down Expand Up @@ -333,6 +335,12 @@ def test_site_to_site_vti(self):
for line in swanctl_secrets_lines:
self.assertRegex(swanctl_conf, fr'{line}')

# Site-to-site interfaces should start out as 'down'
self.assertEqual(Interface(vti).get_admin_state(), 'down')

# Disable PKI
self.tearDownPKI()


def test_dmvpn(self):
tunnel_if = 'tun100'
Expand Down Expand Up @@ -469,9 +477,6 @@ def test_site_to_site_x509(self):
self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{int_ca_name}.pem')))
self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem')))

# There is only one VTI test so no need to delete this globally in tearDown()
self.cli_delete(vti_path)

# Disable PKI
self.tearDownPKI()

Expand Down Expand Up @@ -1224,6 +1229,14 @@ def test_remote_access_vti(self):
self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{ca_name}.pem')))
self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem')))

# Remote access interfaces should be set to 'up' during configure
self.assertEqual(Interface(vti).get_admin_state(), 'up')

# Delete the connection to verify the VTI interfaces is taken down
self.cli_delete(base_path + ['remote-access', 'connection', conn_name])
self.cli_commit()
self.assertEqual(Interface(vti).get_admin_state(), 'down')

self.tearDownPKI()

if __name__ == '__main__':
Expand Down
20 changes: 20 additions & 0 deletions src/conf_mode/vpn_ipsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from vyos.utils.dict import dict_search
from vyos.utils.dict import dict_search_args
from vyos.utils.process import call
from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_for_create_or_update, remove_vti_updown_db
from vyos import ConfigError
from vyos import airbag
airbag.enable()
Expand Down Expand Up @@ -93,6 +94,8 @@ def get_config(config=None):
with_pki=True)

ipsec['dhcp_interfaces'] = set()
ipsec['vti_interfaces'] = set()
ipsec['vti_interfaces_always_up'] = set()
ipsec['dhcp_no_address'] = {}
ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes
ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface'])
Expand Down Expand Up @@ -301,6 +304,10 @@ def verify(ipsec):
if not os.path.exists(f'/sys/class/net/{vti_interface}'):
raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!')

ipsec['vti_interfaces'].add(vti_interface)
# remote access VPN interfaces are always up regardless of whether clients are connected
ipsec['vti_interfaces_always_up'].add(vti_interface)

if 'pool' in ra_conf:
if {'dhcp', 'radius'} <= set(ra_conf['pool']):
raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\
Expand Down Expand Up @@ -490,6 +497,7 @@ def verify(ipsec):
vti_interface = peer_conf['vti']['bind']
if not interface_exists(vti_interface):
raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!')
ipsec['vti_interfaces'].add(vti_interface)

if 'vti' not in peer_conf and 'tunnel' not in peer_conf:
raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}")
Expand Down Expand Up @@ -667,9 +675,21 @@ def apply(ipsec):
systemd_service = 'strongswan.service'
if not ipsec:
call(f'systemctl stop {systemd_service}')

if vti_updown_db_exists():
remove_vti_updown_db()

else:
call(f'systemctl reload-or-restart {systemd_service}')

if ipsec['vti_interfaces']:
with open_vti_updown_db_for_create_or_update() as db:
db.syncInterfaces(ipsec['vti_interfaces'])
db.syncInterfacesAlwaysUp(ipsec['vti_interfaces_always_up'])
db.commit()
elif vti_updown_db_exists():
remove_vti_updown_db()

if ipsec.get('nhrp_exists', False):
try:
call_dependents()
Expand Down
42 changes: 15 additions & 27 deletions src/etc/ipsec.d/vti-up-down
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,30 @@ from syslog import openlog
from syslog import LOG_PID
from syslog import LOG_INFO

from vyos.configquery import ConfigTreeQuery
from vyos.configdict import get_interface_dict
from vyos.ifconfig import VTIIf
from vyos.utils.process import call
from vyos.utils.network import get_interface_config
from vyos.utils.vti_updown_db import open_vti_updown_db_for_update

if __name__ == '__main__':
verb = os.getenv('PLUTO_VERB')
connection = os.getenv('PLUTO_CONNECTION')
interface = sys.argv[1]

if verb.endswith('-v6'):
protocol = 'v6'
else:
protocol = 'v4'

openlog(ident=f'vti-up-down', logoption=LOG_PID, facility=LOG_INFO)
syslog(f'Interface {interface} {verb} {connection}')

if verb in ['up-client', 'up-host']:
if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']:
call('sudo ip route delete default table 220')

vti_link = get_interface_config(interface)

if not vti_link:
syslog(f'Interface {interface} not found')
sys.exit(0)

vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False)

if verb in ['up-client', 'up-host']:
if not vti_link_up:
conf = ConfigTreeQuery()
_, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface)
if 'disable' not in vti:
tmp = VTIIf(interface)
tmp.update(vti)
call(f'sudo ip link set {interface} up')
else:
call(f'sudo ip link set {interface} down')
syslog(f'Interface {interface} is admin down ...')
elif verb in ['down-client', 'down-host']:
if vti_link_up:
call(f'sudo ip link set {interface} down')
if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']:
with open_vti_updown_db_for_update() as db:
db.add(interface, connection, protocol)
db.commit()
elif verb in ['down-client', 'down-client-v6', 'down-host', 'down-host-v6']:
with open_vti_updown_db_for_update() as db:
db.remove(interface, connection, protocol)
db.commit()

0 comments on commit c536878

Please sign in to comment.